diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index eff4f910dae..14d5da7f635 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -186,6 +186,7 @@ class AgentStatisticsQuery(BaseModel): class AgentAppPartial(GenericAppPartial): app_id: str | None = None + debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False published_reference_count: int = 0 @@ -194,6 +195,7 @@ class AgentAppPartial(GenericAppPartial): class AgentAppDetailWithSite(GenericAppDetailWithSite): app_id: str | None = None + debug_conversation_id: str | None = None role: str | None = None active_config_is_published: bool = False @@ -262,6 +264,7 @@ def _serialize_agent_app_detail(app_model) -> dict: payload.pop("bound_agent_id", None) payload["app_id"] = str(app_model.id) payload["id"] = agent.id + payload["debug_conversation_id"] = agent.debug_conversation_id payload["role"] = agent.role or "" payload["active_config_is_published"] = roster_service.active_config_is_published( tenant_id=app_model.tenant_id, @@ -301,6 +304,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: if agent: item["app_id"] = app_id item["id"] = agent.id + item["debug_conversation_id"] = agent.debug_conversation_id item["role"] = agent.role or "" item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False) published_references = published_references_by_agent_id.get(agent.id, []) diff --git a/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py b/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py new file mode 100644 index 00000000000..8e0ea3284c9 --- /dev/null +++ b/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py @@ -0,0 +1,30 @@ +"""add agent debug conversation id + +Revision ID: c8f4a6b2d3e1 +Revises: b2515f9d4c2a +Create Date: 2026-06-22 10:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "c8f4a6b2d3e1" +down_revision = "b2515f9d4c2a" +branch_labels = None +depends_on = None + + +def upgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.add_column(sa.Column("debug_conversation_id", models.types.StringUUID(), nullable=True)) + batch_op.create_index("agent_debug_conversation_id_idx", ["debug_conversation_id"], unique=False) + + +def downgrade(): + with op.batch_alter_table("agents", schema=None) as batch_op: + batch_op.drop_index("agent_debug_conversation_id_idx") + batch_op.drop_column("debug_conversation_id") diff --git a/api/models/agent.py b/api/models/agent.py index 1905377359f..6500eecafea 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -135,6 +135,7 @@ class Agent(DefaultFieldsMixin, Base): Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"), Index("agent_tenant_app_id_idx", "tenant_id", "app_id"), Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"), + Index("agent_debug_conversation_id_idx", "debug_conversation_id"), Index( "agent_tenant_invitable_idx", "tenant_id", @@ -162,6 +163,7 @@ class Agent(DefaultFieldsMixin, Base): scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False) source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False) app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + debug_conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 5d3407f4159..6f507b889b6 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -12066,6 +12066,7 @@ Default namespace | bound_agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | +| debug_conversation_id | string | | No | | deleted_tools | [ [DeletedTool](#deletedtool) ] | | No | | description | string | | No | | enable_api | boolean | | Yes | @@ -12129,6 +12130,7 @@ default (the config form sends the full desired feature state on save). | create_user_name | string | | No | | created_at | integer | | No | | created_by | string | | No | +| debug_conversation_id | string | | No | | description | string | | No | | has_draft_trigger | boolean | | No | | icon | string | | No | diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 00ea86859d8..1ec4f29e6bb 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -3,6 +3,7 @@ from typing import Any, TypedDict from sqlalchemy import and_, func, or_, select from sqlalchemy.exc import IntegrityError +from core.app.entities.app_invoke_entities import InvokeFrom from libs.datetime_utils import naive_utc_now from libs.helper import to_timestamp from models.agent import ( @@ -18,8 +19,8 @@ from models.agent import ( WorkflowAgentNodeBinding, ) from models.agent_config_entities import AgentSoulConfig -from models.enums import AppStatus -from models.model import App, AppMode, IconType +from models.enums import AppStatus, ConversationFromSource, ConversationStatus +from models.model import App, AppMode, Conversation, IconType from models.workflow import Workflow from services.agent.agent_soul_state import agent_soul_has_model from services.agent.composer_validator import ComposerConfigValidator @@ -96,6 +97,7 @@ class AgentRosterService: "scope": agent.scope.value, "source": agent.source.value, "app_id": agent.app_id, + "debug_conversation_id": getattr(agent, "debug_conversation_id", None), "workflow_id": agent.workflow_id, "workflow_node_id": agent.workflow_node_id, "active_config_snapshot_id": agent.active_config_snapshot_id, @@ -391,9 +393,38 @@ class AgentRosterService: self._session.add(revision) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig()) + agent.debug_conversation_id = self._create_agent_app_debug_conversation( + app_id=app_id, + account_id=account_id, + ) self._session.flush() return agent + def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str: + """Create the stable console conversation used by Agent App debug mode.""" + + conversation = Conversation( + app_id=app_id, + app_model_config_id=None, + model_provider=None, + model_id="", + override_model_configs=None, + mode=AppMode.AGENT, + name="Agent Debugging Conversation", + inputs={}, + introduction="", + system_instruction="", + system_instruction_tokens=0, + status=ConversationStatus.NORMAL, + invoke_from=InvokeFrom.DEBUGGER, + from_source=ConversationFromSource.CONSOLE, + from_end_user_id=None, + from_account_id=account_id, + ) + self._session.add(conversation) + self._session.flush() + return conversation.id + def load_app_backing_agents_by_app_id(self, *, tenant_id: str, app_ids: list[str]) -> dict[str, Agent]: """Return active app-backed Agents keyed by Agent App id.""" if not app_ids: diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index 36f98047738..cf02dd3bcc5 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -219,14 +219,22 @@ def test_agent_app_list_and_create_use_agent_route( roster_controller.AgentRosterService, "load_app_backing_agents_by_app_id", lambda _self, **kwargs: { - "app-list": SimpleNamespace(id="agent-list", role="List role", active_config_snapshot_id=None) + "app-list": SimpleNamespace( + id="agent-list", + role="List role", + debug_conversation_id="debug-conversation-list", + active_config_snapshot_id=None, + ) }, ) monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", lambda _self, **kwargs: SimpleNamespace( - id="agent-created", role="Created role", active_config_snapshot_id=None + id="agent-created", + role="Created role", + debug_conversation_id="debug-conversation-created", + active_config_snapshot_id=None, ), ) monkeypatch.setattr( @@ -263,6 +271,7 @@ def test_agent_app_list_and_create_use_agent_route( assert listed["total"] == 1 assert listed["data"][0]["id"] == "agent-list" assert listed["data"][0]["app_id"] == "app-list" + assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list" assert listed["data"][0]["role"] == "List role" assert listed["data"][0]["active_config_is_published"] is False assert listed["data"][0]["published_reference_count"] == 1 @@ -296,6 +305,7 @@ def test_agent_app_list_and_create_use_agent_route( assert status == 201 assert created["id"] == "agent-created" assert created["app_id"] == "app-created" + assert created["debug_conversation_id"] == "debug-conversation-created" assert created["role"] == "Created role" assert created["active_config_is_published"] is False assert "bound_agent_id" not in created @@ -336,7 +346,12 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", - lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="Resolved role", active_config_snapshot_id=None), + lambda _self, **kwargs: SimpleNamespace( + id=agent_id, + role="Resolved role", + debug_conversation_id="debug-conversation-detail", + active_config_snapshot_id=None, + ), ) monkeypatch.setattr( roster_controller.FeatureService, @@ -361,6 +376,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", agent_id) assert detail["id"] == agent_id assert detail["app_id"] == "app-1" + assert detail["debug_conversation_id"] == "debug-conversation-detail" assert detail["role"] == "Resolved role" assert detail["active_config_is_published"] is False assert "bound_agent_id" not in detail @@ -374,6 +390,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert updated["name"] == "Renamed" assert updated["id"] == agent_id assert updated["app_id"] == "app-1" + assert updated["debug_conversation_id"] == "debug-conversation-detail" assert updated["role"] == "Resolved role" assert updated["active_config_is_published"] is False assert "bound_agent_id" not in updated @@ -445,7 +462,12 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon monkeypatch.setattr( roster_controller.AgentRosterService, "get_app_backing_agent", - lambda _self, **kwargs: SimpleNamespace(id=agent_id, role="", active_config_snapshot_id=None), + lambda _self, **kwargs: SimpleNamespace( + id=agent_id, + role="", + debug_conversation_id="debug-conversation-detail", + active_config_snapshot_id=None, + ), ) monkeypatch.setattr( roster_controller.FeatureService, diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index e5acc43c52b..e6ca3bb7ca0 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -23,7 +23,8 @@ from models.agent_config_entities import ( DeclaredOutputType, WorkflowNodeJobConfig, ) -from models.model import IconType +from models.enums import ConversationFromSource, ConversationStatus +from models.model import Conversation, IconType from models.workflow import Workflow from services.agent import composer_service, roster_service from services.agent.agent_soul_state import agent_soul_has_model @@ -1353,6 +1354,7 @@ class TestAgentAppBackingAgent: assert agent.agent_kind == AgentKind.DIFY_AGENT assert agent.name == "Iris" assert agent.role == "research assistant" + assert agent.debug_conversation_id is not None # A v1 snapshot + revision are seeded and wired as the active version. snapshots = [a for a in session.added if isinstance(a, AgentConfigSnapshot)] assert len(snapshots) == 1 @@ -1362,6 +1364,14 @@ class TestAgentAppBackingAgent: a for a in session.added if getattr(a, "operation", None) == AgentConfigRevisionOperation.CREATE_VERSION ] assert len(revisions) == 1 + conversations = [a for a in session.added if isinstance(a, Conversation)] + assert len(conversations) == 1 + assert agent.debug_conversation_id == conversations[0].id + assert conversations[0].app_id == "app-1" + assert conversations[0].mode == "agent" + assert conversations[0].status == ConversationStatus.NORMAL + assert conversations[0].from_source == ConversationFromSource.CONSOLE + assert conversations[0].from_account_id == "account-1" # Caller (AppService.create_app) owns the commit — helper must not commit. assert session.commits == 0 diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 7b82989af89..74acc33ed43 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -29,6 +29,7 @@ export type AgentAppDetailWithSite = { bound_agent_id?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null deleted_tools?: Array description?: string | null enable_api: boolean @@ -329,6 +330,7 @@ export type AgentAppPartial = { create_user_name?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null description?: string | null has_draft_trigger?: boolean | null icon?: string | null @@ -1501,6 +1503,7 @@ export type AgentAppDetailWithSiteWritable = { bound_agent_id?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null deleted_tools?: Array description?: string | null enable_api: boolean @@ -1534,6 +1537,7 @@ export type AgentAppPartialWritable = { create_user_name?: string | null created_at?: number | null created_by?: string | null + debug_conversation_id?: string | null description?: string | null has_draft_trigger?: boolean | null icon?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index ec9f5b0107b..b35427c8bd0 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -684,6 +684,7 @@ export const zAgentAppPartial = z.object({ create_user_name: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), description: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), icon: z.string().nullish(), @@ -747,6 +748,7 @@ export const zAgentAppDetailWithSite = z.object({ bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), deleted_tools: z.array(zDeletedTool).optional(), description: z.string().nullish(), enable_api: z.boolean(), @@ -2086,6 +2088,7 @@ export const zAgentAppPartialWritable = z.object({ create_user_name: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), description: z.string().nullish(), has_draft_trigger: z.boolean().nullish(), icon: z.string().nullish(), @@ -2150,6 +2153,7 @@ export const zAgentAppDetailWithSiteWritable = z.object({ bound_agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + debug_conversation_id: z.string().nullish(), deleted_tools: z.array(zDeletedTool).optional(), description: z.string().nullish(), enable_api: z.boolean(),