diff --git a/api/controllers/common/human_input_v2_contracts.py b/api/controllers/common/human_input_v2_contracts.py
index b098adfb66d574..b3cbf70037a9d2 100644
--- a/api/controllers/common/human_input_v2_contracts.py
+++ b/api/controllers/common/human_input_v2_contracts.py
@@ -167,6 +167,9 @@ class ContactOption(ResponseModel):
name: str = Field(description="Display name shown in the contact picker.")
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
email: str | None = Field(default=None, description="Current deliverable Email, when available.")
+ im_providers: list[IMProvider] = Field(
+ description="Distinct providers of effective IM bindings in the current workspace; not a delivery health check."
+ )
class ExternalContactCreateResponse(ResponseModel):
diff --git a/api/controllers/console/human_input_v2/contact.py b/api/controllers/console/human_input_v2/contact.py
index 9f06e9aa3f9450..bcf96ac1b67555 100644
--- a/api/controllers/console/human_input_v2/contact.py
+++ b/api/controllers/console/human_input_v2/contact.py
@@ -356,13 +356,15 @@ def _contact_payload(view: ContactWithIMBindings) -> dict[str, object]:
}
-def _contact_option_payload(contact: Contact) -> dict[str, object]:
+def _contact_option_payload(view: ContactWithIMBindings) -> dict[str, object]:
+ contact = view.contact
return {
"id": contact.id,
"type": contact.type,
"name": contact.name,
"avatar_url": _contact_avatar_url(contact) or None,
"email": contact.email,
+ "im_providers": list(dict.fromkeys(binding.provider for binding in view.im_bindings)),
}
@@ -469,7 +471,8 @@ class WorkspaceContactOptionsApi(Resource):
params=query_params_from_model(ContactOptionsQuery),
description=(
"List editor-safe Contact options for static recipient selection. "
- "The projection omits IM bindings and management metadata; unavailable Contacts are omitted."
+ "The projection includes effective IM providers and omits binding details and management metadata; "
+ "unavailable Contacts are omitted."
),
)
@console_ns.response(200, "Success", console_ns.models[ListContactOptionsResponse.__name__])
@@ -920,6 +923,7 @@ class BatchGetContactOptionsAPI(Resource):
params=query_params_from_model(BatchGetContactOptionsQuery),
description=(
"Resolve Contact IDs persisted in workflow recipient configuration. "
+ "Include effective IM providers for each Contact. "
"Contacts unavailable in the current workspace are omitted."
),
)
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index f27dcd00daf26c..51fbf07472a6e2 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -14928,7 +14928,7 @@ Update a plugin endpoint
| default | Error | **application/json**: [ErrorResponse](#errorresponse)
|
### [GET] /workspaces/current/human-input/v2/contact-options
-List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted.
+List editor-safe Contact options for static recipient selection. The projection includes effective IM providers and omits binding details and management metadata; unavailable Contacts are omitted.
#### Parameters
@@ -14946,7 +14946,7 @@ List editor-safe Contact options for static recipient selection. The projection
| default | Error | **application/json**: [ErrorResponse](#errorresponse)
|
### [GET] /workspaces/current/human-input/v2/contact-options/batch
-Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted.
+Resolve Contact IDs persisted in workflow recipient configuration. Include effective IM providers for each Contact. Contacts unavailable in the current workspace are omitted.
#### Parameters
@@ -23641,6 +23641,7 @@ Least-privilege contact projection returned to workflow editors.
| avatar_url | string | Signed avatar URL if one is available. | No |
| email | string | Current deliverable Email, when available. | No |
| id | string | Unique contact identifier persisted in workflow recipient configuration. | Yes |
+| im_providers | [ [IMProvider](#improvider) ] | Distinct providers of effective IM bindings in the current workspace; not a delivery health check. | Yes |
| name | string | Display name shown in the contact picker. | Yes |
| type | [HumanInputContactType](#humaninputcontacttype) | Resolved contact type in the current workspace scope. | Yes |
diff --git a/api/services/human_input_v2/contact_service.py b/api/services/human_input_v2/contact_service.py
index 5a2f84b81bee2d..3d92ae8407dd89 100644
--- a/api/services/human_input_v2/contact_service.py
+++ b/api/services/human_input_v2/contact_service.py
@@ -106,20 +106,21 @@ def list_contact_options(
page: int,
limit: int,
keyword: str,
- ) -> Page[Contact]:
- return self._contacts.list_contact(
+ ) -> Page[ContactWithIMBindings]:
+ contact_page = self._contacts.list_contact(
tenant_id,
page,
limit,
ContactQuery(keyword=keyword),
)
+ return Page(self._with_bindings(tenant_id, contact_page.items), contact_page.page, contact_page.limit)
def get_contact_options(
self,
tenant_id: TenantId,
contact_ids: Sequence[ContactId],
- ) -> Sequence[Contact]:
- return self._contacts.get_contacts_by_ids(tenant_id, contact_ids)
+ ) -> tuple[ContactWithIMBindings, ...]:
+ return self._with_bindings(tenant_id, self._contacts.get_contacts_by_ids(tenant_id, contact_ids))
def create_external_contact(
self,
diff --git a/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py b/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py
index 604ad9bfbc2436..4be06966e5037c 100644
--- a/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py
+++ b/api/tests/unit_tests/controllers/console/human_input_v2/test_contact.py
@@ -143,7 +143,14 @@ def test_editing_roles_can_select_contacts(
options = client.get(f"{_BASE_URL}/contact-options")
assert options.status_code == 200
assert options.get_json()["data"] == [
- {"id": contact_id, "type": "external", "name": "Reviewer", "email": "contact@example.com", "avatar_url": None}
+ {
+ "id": contact_id,
+ "type": "external",
+ "name": "Reviewer",
+ "email": "contact@example.com",
+ "avatar_url": None,
+ "im_providers": [],
+ }
]
resolved = client.get(f"{_BASE_URL}/contact-options/batch", query_string={"contact_ids": contact_id})
diff --git a/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py
index 65d4e38d4052fc..3d60c4c6c796ac 100644
--- a/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py
+++ b/api/tests/unit_tests/controllers/console/workspace/test_im_contact_sync_api.py
@@ -766,20 +766,22 @@ def list_contact_options(
page: int,
limit: int,
keyword: str,
- ) -> Page[Contact]:
+ ) -> Page[ContactWithIMBindings]:
assert tenant_id == TenantId("workspace-1")
assert (page, limit, keyword) == (1, 20, "")
- return Page((self.view.contact,), page, limit)
+ return Page((self.view,), page, limit)
def get_contacts(self, tenant_id: TenantId, contact_ids: list[ContactId]) -> tuple[ContactWithIMBindings, ...]:
assert tenant_id == TenantId("workspace-1")
assert contact_ids == [self.view.contact.id]
return (self.view,)
- def get_contact_options(self, tenant_id: TenantId, contact_ids: list[ContactId]) -> tuple[Contact, ...]:
+ def get_contact_options(
+ self, tenant_id: TenantId, contact_ids: list[ContactId]
+ ) -> tuple[ContactWithIMBindings, ...]:
assert tenant_id == TenantId("workspace-1")
assert contact_ids == [self.view.contact.id]
- return (self.view.contact,)
+ return (self.view,)
def test_contact_read_handlers_preserve_current_and_editor_safe_projections(
@@ -818,10 +820,37 @@ def test_contact_read_handlers_preserve_current_and_editor_safe_projections(
assert options["data"][0]["id"] == contact_id
assert batch["data"][0]["id"] == contact_id
assert batch_options["data"][0]["id"] == contact_id
+ assert options["data"][0]["im_providers"] == ["feishu"]
+ assert batch_options["data"] == options["data"]
assert "im_bindings" not in options["data"][0]
assert "email" not in batch["data"][0]
+@pytest.mark.parametrize("provider", list(IMProvider))
+def test_contact_options_return_distinct_effective_im_providers(
+ app: Flask, monkeypatch: pytest.MonkeyPatch, provider: IMProvider
+) -> None:
+ service = _ContactService()
+ binding = replace(service.view.im_bindings[0], provider=provider)
+ service.view = replace(service.view, im_bindings=(binding, binding))
+ monkeypatch.setattr(_CONTROLLER_MODULE, "_contact_service", lambda _session, _tenant_id: service)
+
+ with app.test_request_context(method="GET"):
+ options = unwrap(WorkspaceContactOptionsApi.get)(WorkspaceContactOptionsApi(), object(), "workspace-1")
+ with app.test_request_context(method="GET", query_string=[("contact_ids", str(service.view.contact.id))]):
+ batch = unwrap(BatchGetContactOptionsAPI.get)(BatchGetContactOptionsAPI(), object(), "workspace-1")
+
+ assert options["data"] == batch["data"]
+ assert options["data"][0] == {
+ "id": str(service.view.contact.id),
+ "type": "workspace",
+ "name": "Reviewer",
+ "avatar_url": None,
+ "email": "reviewer@example.com",
+ "im_providers": [provider.value],
+ }
+
+
@pytest.mark.parametrize("scope", [IMBindingScope.WORKSPACE, None])
def test_contact_detail_returns_workspace_binding_or_empty_details(
app: Flask, monkeypatch: pytest.MonkeyPatch, scope: IMBindingScope | None
diff --git a/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py b/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py
index 778d6b30e64af6..538b256bb2708e 100644
--- a/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py
+++ b/api/tests/unit_tests/services/human_input_v2/test_contact_capability_boundaries.py
@@ -187,9 +187,10 @@ def test_tenant_scoped_core_service_needs_only_contact_and_binding_read_ports()
assert tuple(item.contact for item in contacts_with_bindings) == (contact,)
assert selected is not None
assert selected.contact == contact
- assert tuple(options) == (contact,)
+ assert tuple(option.contact for option in options) == (contact,)
+ assert all(not option.im_bindings for option in options)
assert contact_port.tenant_calls == [_TENANT_ID, _TENANT_ID, _TENANT_ID]
- assert binding_port.calls == [(_TENANT_ID, (contact.id,)), (_TENANT_ID, (contact.id,))]
+ assert binding_port.calls == [(_TENANT_ID, (contact.id,)), (_TENANT_ID, (contact.id,)), (_TENANT_ID, (contact.id,))]
def test_candidate_id_round_trips_unchanged_through_the_enterprise_service_port() -> None:
diff --git a/api/tests/unit_tests/services/human_input_v2/test_contact_service.py b/api/tests/unit_tests/services/human_input_v2/test_contact_service.py
new file mode 100644
index 00000000000000..9606e32b2feb30
--- /dev/null
+++ b/api/tests/unit_tests/services/human_input_v2/test_contact_service.py
@@ -0,0 +1,50 @@
+"""Contact option queries include the current effective IM bindings."""
+
+from datetime import datetime
+from unittest.mock import create_autospec
+
+from core.human_input_v2.entities import IMBindingScope, IMProvider
+from core.human_input_v2.shared import ContactId, IMBindingId, IMIdentityId, TenantId
+from repositories.human_input_v2.contact import (
+ Contact,
+ ContactIMBindingRepository,
+ ContactRepository,
+ ContactType,
+ IMBinding,
+ Page,
+)
+from services.human_input_v2.contact_service import ContactManagementService
+
+
+def test_contact_options_include_effective_bindings_for_listed_and_resolved_contacts() -> None:
+ tenant_id = TenantId("workspace-1")
+ contact = Contact(
+ id=ContactId("contact-1"),
+ type=ContactType.WORKSPACE,
+ name="Reviewer",
+ email="reviewer@example.com",
+ avatar_file_id=None,
+ created_at=datetime(2026, 11, 1),
+ )
+ binding = IMBinding(
+ id=IMBindingId("binding-1"),
+ scope=IMBindingScope.WORKSPACE,
+ contact_id=contact.id,
+ identity_id=IMIdentityId("identity-1"),
+ provider=IMProvider.FEISHU,
+ )
+ contacts = create_autospec(ContactRepository, instance=True)
+ contacts.list_contact.return_value = Page((contact,), page=2, limit=10)
+ contacts.get_contacts_by_ids.return_value = (contact,)
+ bindings = create_autospec(ContactIMBindingRepository, instance=True)
+ bindings.get_im_bindings.return_value = (binding,)
+ service = ContactManagementService(contacts, bindings, None)
+
+ page = service.list_contact_options(tenant_id, page=2, limit=10, keyword="reviewer")
+ resolved = service.get_contact_options(tenant_id, (contact.id,))
+
+ assert (page.page, page.limit) == (2, 10)
+ assert len(page.items) == 1
+ assert page.items[0].contact == contact
+ assert page.items[0].im_bindings == (binding,)
+ assert resolved == page.items
diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts
index 7b02b27bf37a24..af91c5ea9faee4 100644
--- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts
@@ -1474,12 +1474,12 @@ export const channels = {
}
/**
- * Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted.
+ * Resolve Contact IDs persisted in workflow recipient configuration. Include effective IM providers for each Contact. Contacts unavailable in the current workspace are omitted.
*/
export const get16 = oc
.route({
description:
- 'Resolve Contact IDs persisted in workflow recipient configuration. Contacts unavailable in the current workspace are omitted.',
+ 'Resolve Contact IDs persisted in workflow recipient configuration. Include effective IM providers for each Contact. Contacts unavailable in the current workspace are omitted.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesCurrentHumanInputV2ContactOptionsBatch',
@@ -1494,12 +1494,12 @@ export const batch = {
}
/**
- * List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted.
+ * List editor-safe Contact options for static recipient selection. The projection includes effective IM providers and omits binding details and management metadata; unavailable Contacts are omitted.
*/
export const get17 = oc
.route({
description:
- 'List editor-safe Contact options for static recipient selection. The projection omits IM bindings and management metadata; unavailable Contacts are omitted.',
+ 'List editor-safe Contact options for static recipient selection. The projection includes effective IM providers and omits binding details and management metadata; unavailable Contacts are omitted.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getWorkspacesCurrentHumanInputV2ContactOptions',
diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts
index 792a2f9d2a3d98..338045b3a9fe78 100644
--- a/packages/contracts/generated/api/console/workspaces/types.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts
@@ -1916,6 +1916,7 @@ export type ContactOption = {
avatar_url?: string | null
email?: string | null
id: string
+ im_providers: Array
name: string
type: HumanInputContactType
}
diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts
index 627fb4f003491b..651986b0612bf9 100644
--- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts
+++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts
@@ -2946,6 +2946,7 @@ export const zContactOption = z.object({
avatar_url: z.string().nullish(),
email: z.string().nullish(),
id: z.string(),
+ im_providers: z.array(zImProvider),
name: z.string(),
type: zHumanInputContactType,
})
diff --git a/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts b/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts
index 7f6819e9042312..a1232f2f4be13f 100644
--- a/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts
+++ b/web/app/components/workflow/nodes/human-input-v2/__tests__/contact-provider.spec.ts
@@ -34,7 +34,14 @@ vi.mock('@/service/console', async () => {
const firstPage: ListContactOptionsResponse = {
data: [
- { id: 'contact-1', name: 'First contact', email: null, avatar_url: null, type: 'platform' },
+ {
+ id: 'contact-1',
+ name: 'First contact',
+ email: null,
+ avatar_url: null,
+ type: 'platform',
+ im_providers: [],
+ },
],
page: 1,
limit: 20,
@@ -50,7 +57,13 @@ describe('Contact recipient API provider', () => {
api.search.mockResolvedValueOnce(firstPage).mockResolvedValueOnce({
...firstPage,
data: [
- { id: 'contact-21', name: 'Last contact', email: 'last@example.com', type: 'external' },
+ {
+ id: 'contact-21',
+ name: 'Last contact',
+ email: 'last@example.com',
+ type: 'external',
+ im_providers: [],
+ },
],
page: 2,
})