diff --git a/src/chat_sdk/ai/__init__.py b/src/chat_sdk/ai/__init__.py index 41d1f75..39140d5 100644 --- a/src/chat_sdk/ai/__init__.py +++ b/src/chat_sdk/ai/__init__.py @@ -1,11 +1,12 @@ """AI SDK integration for the chat SDK. Python port of the ``chat/ai`` subpath. Mirrors the upstream structure where -``ai.ts`` was split into ``ai/messages.ts`` (and, in later PRs, ``ai/tools.ts``) -to make room for tool factories. +``ai.ts`` was split into ``ai/messages.ts`` and ``ai/tools.ts`` (plus the +``ai/tools/*`` helpers) to make room for the tool factory surface. -Re-exports everything the former ``chat_sdk.ai`` module exposed so existing -imports such as ``from chat_sdk.ai import to_ai_messages`` keep working. +Re-exports the message-conversion helpers and the tool factory + supporting +types so callers can do ``from chat_sdk.ai import to_ai_messages, +create_chat_tools`` regardless of how upstream splits the source files. """ from __future__ import annotations @@ -21,6 +22,36 @@ ToAiMessagesOptions, to_ai_messages, ) +from chat_sdk.ai.tools import ( + ApprovalConfig, + ChatBinding, + ChatTool, + ChatToolName, + ChatToolPreset, + ChatTools, + ChatToolsOptions, + ChatWriteToolName, + ToolOptions, + ToolOverrides, + add_reaction, + create_chat_tools, + delete_message, + edit_message, + fetch_channel_messages, + fetch_messages, + fetch_thread, + get_channel_info, + get_thread_participants, + get_user, + list_threads, + post_channel_message, + post_message, + remove_reaction, + send_direct_message, + start_typing, + subscribe_thread, + unsubscribe_thread, +) __all__ = [ "AiAssistantMessage", @@ -30,6 +61,34 @@ "AiMessagePart", "AiTextPart", "AiUserMessage", + "ApprovalConfig", + "ChatBinding", + "ChatTool", + "ChatToolName", + "ChatToolPreset", + "ChatTools", + "ChatToolsOptions", + "ChatWriteToolName", "ToAiMessagesOptions", + "ToolOptions", + "ToolOverrides", + "add_reaction", + "create_chat_tools", + "delete_message", + "edit_message", + "fetch_channel_messages", + "fetch_messages", + "fetch_thread", + "get_channel_info", + "get_thread_participants", + "get_user", + "list_threads", + "post_channel_message", + "post_message", + "remove_reaction", + "send_direct_message", + "start_typing", + "subscribe_thread", + "unsubscribe_thread", "to_ai_messages", ] diff --git a/src/chat_sdk/ai/tools.py b/src/chat_sdk/ai/tools.py new file mode 100644 index 0000000..ea84d36 --- /dev/null +++ b/src/chat_sdk/ai/tools.py @@ -0,0 +1,1133 @@ +"""Tool factory for exposing Chat SDK operations to AI agents. + +Python port of ``packages/chat/src/ai/tools.ts`` (and the supporting +``tools/{channels,messages,reactions,threads,users}.ts`` / ``types.ts`` +files) introduced by `vercel/chat#492`_. + +The TS implementation builds on the Vercel AI SDK's :func:`tool` helper and +``zod`` schemas. Python has no direct equivalent: there's no canonical AI +agent runtime in the standard library, and adding ``pydantic`` (or any other +runtime) would couple ``chat_sdk`` to a third-party schema validator. To +keep the surface framework-agnostic — and faithful to the upstream contract +that a tool is "a description + an input schema + an ``execute`` callable" — +each factory returns a plain :class:`ChatTool` dataclass holding: + +* ``description`` — the natural-language description shown to the model. +* ``input_schema`` — a JSON-Schema-shaped :class:`dict` describing the tool + inputs. Consumers that bind these tools into the Vercel AI SDK (via the + ``@ai-sdk/python``-style bridge) or any other agent runtime can feed this + dict directly to their schema layer. +* ``needs_approval`` — mirrors upstream's ``needsApproval`` flag for + human-in-the-loop write tools. Falsy for read-only tools, ``True`` by + default for writes. +* ``execute`` — an ``async`` callable taking a ``dict`` of validated + arguments and returning the tool result. + +The factory entry point :func:`create_chat_tools` mirrors upstream's +``createChatTools`` exactly: presets, ``require_approval`` config (bool or +per-tool mapping), per-tool ``overrides``, and the same set of protected +core fields that overrides cannot replace. + +.. _vercel/chat#492: https://github.com/vercel/chat/pull/492 +""" + +from __future__ import annotations + +import copy +from collections.abc import Awaitable, Callable +from dataclasses import dataclass, field, replace +from typing import Any, Literal + +from chat_sdk.chat import Chat +from chat_sdk.errors import ChatError, ChatNotImplementedError +from chat_sdk.types import ( + Author, + FetchOptions, + ListThreadsOptions, + Message, + PostableMarkdown, + PostableRaw, +) + +# --------------------------------------------------------------------------- +# Tool dataclass +# --------------------------------------------------------------------------- + + +@dataclass +class ChatTool: + """A Chat SDK tool exposed to an AI agent. + + Mirrors the runtime shape produced by upstream's ``tool({...})`` helper: + a ``description`` + an ``input_schema`` (JSON-Schema-shaped) + an + ``execute`` coroutine. ``needs_approval`` is ``True`` for write tools so + callers can gate execution behind human approval (matching upstream's + ``needsApproval`` flag). + + Additional upstream fields exposed via the ``overrides`` config — + ``title``, ``input_examples``, ``metadata``, ``provider_options``, + ``strict``, ``to_model_output``, ``on_input_available``, + ``on_input_delta``, ``on_input_start`` — are stored in :attr:`extras` + as a free-form dict so callers can forward them to whatever agent + runtime they bind these tools into. + """ + + description: str + input_schema: dict[str, Any] + execute: Callable[[dict[str, Any]], Awaitable[Any]] + needs_approval: bool | None = None + extras: dict[str, Any] = field(default_factory=dict) + + +# --------------------------------------------------------------------------- +# Aliases mirroring upstream type names +# --------------------------------------------------------------------------- + +#: Alias for the ``Chat`` instance threaded through every tool factory. +#: Upstream types this as ``Chat``; in Python ``Chat`` is already +#: untyped at the adapter level so a plain alias is enough. +ChatBinding = Chat + + +@dataclass +class ToolOptions: + """Common options for write tools that may require approval before executing. + + Mirrors upstream's ``ToolOptions`` interface. + """ + + needs_approval: bool = True + + +#: Partial overrides for a single tool. Mirrors upstream's ``ToolOverrides``, +#: but ``input_schema``/``execute``/``output_schema`` etc. are filtered out +#: at apply-time (see :data:`_PROTECTED_TOOL_FIELDS`) so semantics stay +#: stable. +ToolOverrides = dict[str, Any] + + +# --------------------------------------------------------------------------- +# Tool names +# --------------------------------------------------------------------------- + +#: Every tool name produced by :func:`create_chat_tools`. Kept as a +#: ``Literal`` alias instead of an enum so the mapping types below can be +#: expressed naturally. +ChatToolName = Literal[ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + "startTyping", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", +] + +#: Names of every tool that mutates platform state. These default to +#: ``needs_approval=True`` and can be toggled via ``require_approval`` on +#: :func:`create_chat_tools`. +ChatWriteToolName = Literal[ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", +] + +#: Whether write operations require user approval. +#: +#: - ``True`` — every write tool needs approval (default) +#: - ``False`` — no write tool needs approval +#: - ``dict`` — per-tool override; unspecified write tools default to +#: ``True`` +ApprovalConfig = bool | dict[str, bool] + +#: Predefined tool presets for common chat-agent use cases. +#: +#: - ``"reader"`` — read-only: fetch threads, messages, channel info, +#: users +#: - ``"messenger"`` — basic posting: post in thread/channel, DM, react, +#: typing +#: - ``"moderator"`` — full management: read + write + edit/delete + +#: subscriptions +ChatToolPreset = Literal["reader", "messenger", "moderator"] + + +_PRESET_TOOLS: dict[str, list[str]] = { + "reader": [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + ], + "messenger": [ + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getUser", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "addReaction", + "removeReaction", + "startTyping", + ], + "moderator": [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + "startTyping", + ], +} + + +# Fields that overrides cannot replace. Mirrors upstream's +# ``PROTECTED_TOOL_FIELDS``. ``args``/``id``/``output_schema``/``type``/ +# ``supports_deferred_results`` come from upstream's AI SDK shape; they're +# included verbatim so consumers porting upstream ``overrides`` dicts get +# the same protection. +_PROTECTED_TOOL_FIELDS: frozenset[str] = frozenset( + { + "args", + "execute", + "id", + "input_schema", + "inputSchema", + "output_schema", + "outputSchema", + "supports_deferred_results", + "supportsDeferredResults", + "type", + } +) + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _project_message(message: Message) -> dict[str, Any]: + """Flatten a :class:`~chat_sdk.types.Message` for model consumption. + + Mirrors upstream's ``projectMessage`` helper. Field names use camelCase + so that JSON dumps of the returned dicts match the upstream wire shape; + Python callers who want snake_case can rename downstream. + """ + return { + "id": message.id, + "threadId": message.thread_id, + "text": message.text, + "author": { + "userId": message.author.user_id, + "userName": message.author.user_name, + "fullName": message.author.full_name, + "isBot": message.author.is_bot, + "isMe": message.author.is_me, + }, + "dateSent": (message.metadata.date_sent.isoformat() if message.metadata.date_sent else None), + "edited": message.metadata.edited, + "isMention": getattr(message, "is_mention", False), + "attachments": [ + { + "type": att.type, + "name": att.name, + "mimeType": att.mime_type, + "url": att.url, + } + for att in (message.attachments if message.attachments is not None else []) + ], + } + + +def _project_author(author: Author) -> dict[str, Any]: + return { + "userId": author.user_id, + "userName": author.user_name, + "fullName": author.full_name, + "isBot": author.is_bot, + } + + +# ``message`` arg shape — one of: plain string, ``{"markdown": "..."}``, +# ``{"raw": "..."}``. Mirrors upstream's ``POSTABLE_INPUT`` union. +_POSTABLE_INPUT_SCHEMA: dict[str, Any] = { + "oneOf": [ + {"type": "string", "description": "Plain text body"}, + { + "type": "object", + "properties": {"markdown": {"type": "string"}}, + "required": ["markdown"], + "additionalProperties": False, + "description": "Markdown body, converted to the platform's native format", + }, + { + "type": "object", + "properties": {"raw": {"type": "string"}}, + "required": ["raw"], + "additionalProperties": False, + "description": "Raw body, passed through to the platform untouched", + }, + ], + "description": "Message body", +} + + +def _to_postable(message: Any) -> Any: + """Translate a tool-input ``message`` value to the SDK's postable form. + + Accepts a plain string, ``{"markdown": "..."}``, or ``{"raw": "..."}``. + The string and ``{"raw": ...}`` cases pass through unchanged so the + adapter can decide how to handle them; ``{"markdown": ...}`` is wrapped + in a :class:`~chat_sdk.types.PostableMarkdown` so the SDK's markdown + rendering path runs. + """ + if isinstance(message, str): + return message + if isinstance(message, dict): + if "markdown" in message: + return PostableMarkdown(markdown=message["markdown"]) + if "raw" in message: + return PostableRaw(raw=message["raw"]) + # Fall through — pass anything else through unchanged so adapters that + # accept their own postable shapes (e.g. a card) still work. + return message + + +# --------------------------------------------------------------------------- +# channels.ts +# --------------------------------------------------------------------------- + + +def get_channel_info(chat: ChatBinding) -> ChatTool: + """Fetch metadata for a channel (name, member count, DM status, etc.).""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel = chat.channel(args["channelId"]) + info = await channel.fetch_metadata() + return { + "id": info.id, + "name": info.name, + "isDM": info.is_dm if info.is_dm is not None else False, + "memberCount": info.member_count, + "channelVisibility": info.channel_visibility, + } + + return ChatTool( + description=( + "Fetch metadata for a channel: name, member count, DM status, " + "visibility, etc. Use to identify a channel before posting." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": { + "type": "string", + "description": "Full channel id including adapter prefix", + }, + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# messages.ts +# --------------------------------------------------------------------------- + + +def post_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Post a message inside an existing thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + sent = await thread.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Post a message inside an existing thread. Use this to reply within a " + "conversation the bot already has context for. The threadId is the " + "full id (e.g. 'slack:C123:1234567890.123456')." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id including adapter prefix", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["threadId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def post_channel_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Post a top-level channel message (not threaded under another message).""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel = chat.channel(args["channelId"]) + sent = await channel.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Post a top-level message to a channel (not threaded under an existing " + "message). The channelId is the full id (e.g. 'slack:C123ABC')." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": { + "type": "string", + "description": "Full channel id including adapter prefix", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["channelId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def send_direct_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Open (or reuse) a 1:1 DM with a user and post in it.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + dm = await chat.open_dm(args["userId"]) + sent = await dm.post(_to_postable(args["message"])) + return {"messageId": sent.id, "threadId": sent.thread_id} + + return ChatTool( + description=( + "Open (or reuse) a 1:1 direct-message conversation with a user and post " + "a message in it. The userId format is platform-specific (e.g. 'U123456' " + "for Slack, 'users/123' for Google Chat)." + ), + input_schema={ + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Platform-specific user id; the adapter is auto-detected", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["userId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def edit_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Edit a previously posted message in a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + result = await thread.adapter.edit_message( + args["threadId"], + args["messageId"], + _to_postable(args["message"]), + ) + return {"messageId": result.id, "threadId": result.thread_id} + + return ChatTool( + description=( + "Edit a previously posted message in a thread. Replaces the existing " + "message body. Only messages the bot itself authored can be edited on " + "most platforms." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id of the message to edit", + }, + "message": copy.deepcopy(_POSTABLE_INPUT_SCHEMA), + }, + "required": ["threadId", "messageId", "message"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def delete_message(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Delete a message from a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.delete_message(args["threadId"], args["messageId"]) + return { + "deleted": True, + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description=( + "Delete a message from a thread. Only messages the bot itself authored can be deleted on most platforms." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id of the message to delete", + }, + }, + "required": ["threadId", "messageId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +# --------------------------------------------------------------------------- +# reactions.ts +# --------------------------------------------------------------------------- + + +def add_reaction(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Add an emoji reaction to a specific message.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.add_reaction(args["threadId"], args["messageId"], args["emoji"]) + return { + "added": True, + "emoji": args["emoji"], + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description=( + "Add an emoji reaction to a specific message. Use a well-known emoji " + "name (e.g. 'thumbs_up', 'heart', 'check') or a platform-native shorthand." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id to react to", + }, + "emoji": { + "type": "string", + "description": ("Emoji name or platform shortcode (e.g. 'thumbs_up', 'white_check_mark')"), + }, + }, + "required": ["threadId", "messageId", "emoji"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def remove_reaction(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Remove an emoji reaction the bot previously added.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.adapter.remove_reaction(args["threadId"], args["messageId"], args["emoji"]) + return { + "removed": True, + "emoji": args["emoji"], + "messageId": args["messageId"], + "threadId": args["threadId"], + } + + return ChatTool( + description="Remove an emoji reaction the bot previously added to a message.", + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "messageId": { + "type": "string", + "description": "Platform-specific message id to remove the reaction from", + }, + "emoji": { + "type": "string", + "description": "Emoji name or platform shortcode previously added by the bot", + }, + }, + "required": ["threadId", "messageId", "emoji"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +# --------------------------------------------------------------------------- +# threads.ts +# --------------------------------------------------------------------------- + + +_FETCH_DIRECTION_SCHEMA: dict[str, Any] = { + "type": "string", + "enum": ["forward", "backward"], + "default": "backward", +} + + +def fetch_messages(chat: ChatBinding) -> ChatTool: + """Fetch recent messages from a thread, oldest-first within the page.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + limit = args.get("limit", 20) + cursor = args.get("cursor") + direction = args.get("direction", "backward") + result = await thread.adapter.fetch_messages( + args["threadId"], + FetchOptions(limit=limit, cursor=cursor, direction=direction), + ) + return { + "messages": [_project_message(m) for m in result.messages], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "Fetch recent messages from a thread, ordered chronologically (oldest " + "first within the page). Use to read the conversation before responding." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + "description": "Maximum number of messages to fetch", + }, + "cursor": { + "type": "string", + "description": "Pagination cursor from a previous fetchMessages call", + }, + "direction": { + **copy.deepcopy(_FETCH_DIRECTION_SCHEMA), + "description": ( + "'backward' (default) returns the most recent messages; 'forward' iterates from the oldest" + ), + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def fetch_channel_messages(chat: ChatBinding) -> ChatTool: + """Fetch top-level messages in a channel (not thread replies).""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel_id: str = args["channelId"] + adapter_name = channel_id.split(":")[0] if ":" in channel_id else "" + adapter = chat.get_adapter(adapter_name) if adapter_name else None + fetch_method = getattr(adapter, "fetch_channel_messages", None) if adapter is not None else None + if fetch_method is None: + raise ChatError(f'Adapter "{adapter_name}" does not support fetching channel messages') + + limit = args.get("limit", 20) + cursor = args.get("cursor") + direction = args.get("direction", "backward") + try: + result = await fetch_method( + channel_id, + FetchOptions(limit=limit, cursor=cursor, direction=direction), + ) + except ChatNotImplementedError as exc: + raise ChatError(f'Adapter "{adapter_name}" does not support fetching channel messages') from exc + return { + "messages": [_project_message(m) for m in result.messages], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "Fetch top-level messages in a channel (not thread replies). Returns " + "messages in chronological order within the page." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": {"type": "string", "description": "Full channel id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + }, + "cursor": {"type": "string"}, + "direction": copy.deepcopy(_FETCH_DIRECTION_SCHEMA), + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def fetch_thread(chat: ChatBinding) -> ChatTool: + """Fetch metadata about a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + info = await thread.adapter.fetch_thread(args["threadId"]) + return { + "id": info.id, + "channelId": info.channel_id, + "channelName": info.channel_name, + "channelVisibility": info.channel_visibility, + "isDM": info.is_dm if info.is_dm is not None else False, + } + + return ChatTool( + description=("Fetch metadata about a thread (channel id, channel name, visibility, DM status, etc)."), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def list_threads(chat: ChatBinding) -> ChatTool: + """List recent threads in a channel.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + channel_id: str = args["channelId"] + adapter_name = channel_id.split(":")[0] if ":" in channel_id else "" + adapter = chat.get_adapter(adapter_name) if adapter_name else None + list_method = getattr(adapter, "list_threads", None) if adapter is not None else None + if list_method is None: + raise ChatError(f'Adapter "{adapter_name}" does not support listing threads') + + limit = args.get("limit", 20) + cursor = args.get("cursor") + try: + result = await list_method(channel_id, options=ListThreadsOptions(limit=limit, cursor=cursor)) + except ChatNotImplementedError as exc: + raise ChatError(f'Adapter "{adapter_name}" does not support listing threads') from exc + return { + "threads": [ + { + "id": t.id, + "replyCount": t.reply_count, + "lastReplyAt": t.last_reply_at.isoformat() if t.last_reply_at else None, + "rootMessage": _project_message(t.root_message), + } + for t in result.threads + ], + "nextCursor": result.next_cursor, + } + + return ChatTool( + description=( + "List recent threads in a channel. Returns lightweight summaries with the root message of each thread." + ), + input_schema={ + "type": "object", + "properties": { + "channelId": {"type": "string", "description": "Full channel id"}, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20, + }, + "cursor": {"type": "string"}, + }, + "required": ["channelId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def get_thread_participants(chat: ChatBinding) -> ChatTool: + """Return the unique non-bot participants in a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + participants = await thread.get_participants() + return {"participants": [_project_author(p) for p in participants]} + + return ChatTool( + description=( + "Return the unique non-bot participants in a thread. Useful for " + "deciding whether to subscribe (1:1) or stay quiet (group)." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +def subscribe_thread(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Subscribe to all future messages in a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.subscribe() + return {"subscribed": True, "threadId": args["threadId"]} + + return ChatTool( + description=( + "Subscribe to all future messages in a thread. After subscribing, the " + "bot will receive every message in this thread (not just @mentions)." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id to subscribe to", + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def unsubscribe_thread(chat: ChatBinding, options: ToolOptions | None = None) -> ChatTool: + """Unsubscribe from a thread.""" + opts = options if options is not None else ToolOptions() + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.unsubscribe() + return {"subscribed": False, "threadId": args["threadId"]} + + return ChatTool( + description=("Unsubscribe from a thread. The bot will stop receiving non-mention messages in this thread."), + input_schema={ + "type": "object", + "properties": { + "threadId": { + "type": "string", + "description": "Full thread id to unsubscribe from", + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + needs_approval=opts.needs_approval, + ) + + +def start_typing(chat: ChatBinding) -> ChatTool: + """Show a typing indicator in a thread.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any]: + thread = chat.thread(args["threadId"]) + await thread.start_typing(args.get("status")) + return {"typing": True, "threadId": args["threadId"]} + + return ChatTool( + description=( + "Show a typing indicator in a thread. Use this when starting a " + "long-running operation so users know the bot is working." + ), + input_schema={ + "type": "object", + "properties": { + "threadId": {"type": "string", "description": "Full thread id"}, + "status": { + "type": "string", + "description": ("Optional human-readable status (some platforms display this, others ignore it)"), + }, + }, + "required": ["threadId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# users.ts +# --------------------------------------------------------------------------- + + +def get_user(chat: ChatBinding) -> ChatTool: + """Look up profile information about a user by platform-specific id.""" + + async def _execute(args: dict[str, Any]) -> dict[str, Any] | None: + user = await chat.get_user(args["userId"]) + if not user: + return None + return { + "userId": user.user_id, + "userName": user.user_name, + "fullName": user.full_name, + "email": user.email, + "isBot": user.is_bot, + "avatarUrl": user.avatar_url, + } + + return ChatTool( + description=( + "Look up profile information about a user by their platform-specific id " + "(e.g. 'U123456' for Slack, '29:...' for Teams, 'users/123' for Google " + "Chat). Returns null if the user is unknown." + ), + input_schema={ + "type": "object", + "properties": { + "userId": { + "type": "string", + "description": "Platform-specific user id; the adapter is auto-detected", + }, + }, + "required": ["userId"], + "additionalProperties": False, + }, + execute=_execute, + ) + + +# --------------------------------------------------------------------------- +# Orchestrator: createChatTools +# --------------------------------------------------------------------------- + + +def _resolve_approval(tool_name: str, config: ApprovalConfig) -> bool: + if isinstance(config, bool): + return config + return config.get(tool_name, True) + + +def _resolve_preset_tools(preset: ChatToolPreset | list[ChatToolPreset]) -> set[str]: + presets: list[str] = [preset] if isinstance(preset, str) else list(preset) + tools: set[str] = set() + for p in presets: + if p not in _PRESET_TOOLS: + raise ChatError(f'Unknown preset: "{p}"') + for t in _PRESET_TOOLS[p]: + tools.add(t) + return tools + + +def _apply_overrides(tool: ChatTool, overrides: ToolOverrides | None) -> ChatTool: + """Apply tool overrides while blocking attempts to replace core fields. + + Core fields — ``execute``, ``input_schema``, etc. — are filtered out so + that overrides can never break tool semantics. ``description`` and + ``needs_approval`` are first-class fields on :class:`ChatTool` and are + applied directly; everything else is stashed in :attr:`ChatTool.extras` + for downstream agent runtimes to pick up. + """ + if not overrides: + return tool + + safe = {k: v for k, v in overrides.items() if k not in _PROTECTED_TOOL_FIELDS} + + description = safe.pop("description", tool.description) + needs_approval = safe.pop("needs_approval", safe.pop("needsApproval", tool.needs_approval)) + + extras = {**tool.extras, **safe} + return replace( + tool, + description=description, + needs_approval=needs_approval, + extras=extras, + ) + + +@dataclass +class ChatToolsOptions: + """Options for :func:`create_chat_tools`. Mirrors upstream's ``ChatToolsOptions``.""" + + chat: ChatBinding + overrides: dict[str, ToolOverrides] | None = None + preset: ChatToolPreset | list[ChatToolPreset] | None = None + require_approval: ApprovalConfig = True + + +def create_chat_tools( + chat: ChatBinding | None = None, + *, + preset: ChatToolPreset | list[ChatToolPreset] | None = None, + require_approval: ApprovalConfig = True, + overrides: dict[str, ToolOverrides] | None = None, +) -> dict[str, ChatTool]: + """Create a set of Chat SDK tools for an AI agent. + + Mirrors upstream's ``createChatTools`` from ``chat/ai``: returns a dict + keyed by ``ChatToolName`` (camelCase, matching upstream so consumers + that bind to AI runtimes get the same tool ids). + + Each entry is built lazily so a preset filter skips both the + ``approval`` lookup and the underlying tool construction for tools the + agent will never see — same optimization as upstream. + + Parameters mirror upstream 1:1: + + chat: + The :class:`~chat_sdk.chat.Chat` instance the tools dispatch + operations against. **Required.** + preset: + Optional preset or list of presets to scope the returned toolset. + Omit (or pass ``None``) to get every tool. + require_approval: + ``True`` (default) to require human approval for every write tool; + ``False`` to disable approval globally; or a per-tool ``dict`` + where unspecified write tools default to ``True``. + overrides: + Per-tool overrides. Mirrors upstream's behaviour: core fields + cannot be overridden (see :data:`_PROTECTED_TOOL_FIELDS`). + """ + if chat is None: + raise ChatError( + "createChatTools requires a `chat` instance. Pass your `Chat({ ... })` instance as the `chat` option." + ) + + allowed: set[str] | None = _resolve_preset_tools(preset) if preset is not None else None + + def _approval(name: str) -> ToolOptions: + return ToolOptions(needs_approval=_resolve_approval(name, require_approval)) + + factories: dict[str, Callable[[], ChatTool]] = { + "fetchMessages": lambda: fetch_messages(chat), + "fetchChannelMessages": lambda: fetch_channel_messages(chat), + "fetchThread": lambda: fetch_thread(chat), + "listThreads": lambda: list_threads(chat), + "getThreadParticipants": lambda: get_thread_participants(chat), + "getChannelInfo": lambda: get_channel_info(chat), + "getUser": lambda: get_user(chat), + "startTyping": lambda: start_typing(chat), + "postMessage": lambda: post_message(chat, _approval("postMessage")), + "postChannelMessage": lambda: post_channel_message(chat, _approval("postChannelMessage")), + "sendDirectMessage": lambda: send_direct_message(chat, _approval("sendDirectMessage")), + "editMessage": lambda: edit_message(chat, _approval("editMessage")), + "deleteMessage": lambda: delete_message(chat, _approval("deleteMessage")), + "addReaction": lambda: add_reaction(chat, _approval("addReaction")), + "removeReaction": lambda: remove_reaction(chat, _approval("removeReaction")), + "subscribeThread": lambda: subscribe_thread(chat, _approval("subscribeThread")), + "unsubscribeThread": lambda: unsubscribe_thread(chat, _approval("unsubscribeThread")), + } + + result: dict[str, ChatTool] = {} + overrides_map = overrides if overrides is not None else {} + for name, build in factories.items(): + if allowed is not None and name not in allowed: + continue + built = build() + result[name] = _apply_overrides(built, overrides_map.get(name)) + return result + + +#: Alias matching upstream's ``ChatTools`` — the shape returned by +#: :func:`create_chat_tools`. Kept as an alias rather than a distinct type +#: so consumers can treat the result as a plain dict. +ChatTools = dict[str, ChatTool] + + +__all__ = [ + "ApprovalConfig", + "ChatBinding", + "ChatTool", + "ChatToolName", + "ChatToolPreset", + "ChatTools", + "ChatToolsOptions", + "ChatWriteToolName", + "ToolOptions", + "ToolOverrides", + "add_reaction", + "create_chat_tools", + "delete_message", + "edit_message", + "fetch_channel_messages", + "fetch_messages", + "fetch_thread", + "get_channel_info", + "get_thread_participants", + "get_user", + "list_threads", + "post_channel_message", + "post_message", + "remove_reaction", + "send_direct_message", + "start_typing", + "subscribe_thread", + "unsubscribe_thread", +] diff --git a/tests/test_ai_tools.py b/tests/test_ai_tools.py new file mode 100644 index 0000000..3bb5333 --- /dev/null +++ b/tests/test_ai_tools.py @@ -0,0 +1,815 @@ +"""Tests for ``chat_sdk.ai.tools``. + +Mirrors the upstream Vitest suite in +``packages/chat/src/ai/index.test.ts`` (vercel/chat#492). Each test is +load-bearing — exercising a specific contract of either the +``create_chat_tools`` orchestrator (presets, approval config, override +filtering) or a specific tool factory's ``execute`` path. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any +from unittest.mock import AsyncMock + +import pytest + +from chat_sdk import Chat +from chat_sdk.ai import ( + ChatTool, + create_chat_tools, +) +from chat_sdk.errors import ChatError, ChatNotImplementedError +from chat_sdk.shared.mock_adapter import ( + MockAdapter, + MockStateAdapter, + create_mock_adapter, + create_mock_state, + create_test_message, + mock_logger, +) +from chat_sdk.types import ( + ChannelInfo, + FetchResult, + ListThreadsResult, + PostableMarkdown, + PostableRaw, + ThreadInfo, + ThreadSummary, + UserInfo, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@dataclass +class _Harness: + chat: Chat + adapter: MockAdapter + state: MockStateAdapter + + +@pytest.fixture +async def harness() -> _Harness: + adapter = create_mock_adapter("slack") + state = create_mock_state() + chat = Chat( + user_name="testbot", + adapters={"slack": adapter}, + state=state, + logger=mock_logger, + ) + return _Harness(chat=chat, adapter=adapter, state=state) + + +# --------------------------------------------------------------------------- +# Orchestrator: createChatTools +# --------------------------------------------------------------------------- + + +class TestCreateChatToolsShape: + """Tests for the ``create_chat_tools`` return shape, presets, and validation.""" + + async def test_returns_full_toolset_when_no_preset_supplied(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + assert sorted(tools.keys()) == sorted( + [ + "addReaction", + "deleteMessage", + "editMessage", + "fetchChannelMessages", + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getThreadParticipants", + "getUser", + "listThreads", + "postChannelMessage", + "postMessage", + "removeReaction", + "sendDirectMessage", + "startTyping", + "subscribeThread", + "unsubscribeThread", + ] + ) + + async def test_requires_a_chat_instance(self): + with pytest.raises(ChatError, match="requires a `chat` instance"): + create_chat_tools(chat=None) + + async def test_scopes_tools_to_single_preset(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, preset="reader") + names = sorted(tools.keys()) + assert names == sorted( + [ + "fetchChannelMessages", + "fetchMessages", + "fetchThread", + "getChannelInfo", + "getThreadParticipants", + "getUser", + "listThreads", + ] + ) + # No write tools at all + assert "postMessage" not in names + assert "deleteMessage" not in names + + async def test_composes_multiple_presets(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, preset=["reader", "messenger"]) + names = set(tools.keys()) + assert "postMessage" in names + assert "fetchMessages" in names + assert "listThreads" in names + # Neither preset includes deleteMessage / editMessage + assert "deleteMessage" not in names + assert "editMessage" not in names + + async def test_rejects_unknown_preset_name(self, harness: _Harness): + with pytest.raises(ChatError, match="Unknown preset"): + create_chat_tools(chat=harness.chat, preset="superuser") # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Approval semantics +# --------------------------------------------------------------------------- + + +class TestRequireApproval: + """Tests for the ``require_approval`` config (bool + per-tool mapping).""" + + async def test_every_write_tool_defaults_to_needs_approval_true(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + write_tools = [ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ] + for name in write_tools: + assert tools[name].needs_approval is True, name + + async def test_read_only_tools_never_gate_on_approval(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + read_tools = [ + "fetchMessages", + "fetchChannelMessages", + "fetchThread", + "listThreads", + "getThreadParticipants", + "getChannelInfo", + "getUser", + # Typing indicator is harmless and never gated + "startTyping", + ] + for name in read_tools: + assert tools[name].needs_approval is None, name + + async def test_require_approval_false_disables_all(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + write_tools = [ + "postMessage", + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ] + for name in write_tools: + assert tools[name].needs_approval is False, name + + async def test_per_tool_approval_overrides(self, harness: _Harness): + tools = create_chat_tools( + chat=harness.chat, + require_approval={ + "postMessage": False, + "deleteMessage": True, + "subscribeThread": False, + }, + ) + assert tools["postMessage"].needs_approval is False + assert tools["deleteMessage"].needs_approval is True + assert tools["subscribeThread"].needs_approval is False + # Unspecified write tools fall back to True + assert tools["editMessage"].needs_approval is True + assert tools["unsubscribeThread"].needs_approval is True + + +# --------------------------------------------------------------------------- +# Override semantics +# --------------------------------------------------------------------------- + + +class TestOverrides: + """Tests for per-tool overrides (descriptions, extras, protected fields).""" + + async def test_applies_overrides_without_breaking_execution(self, harness: _Harness): + tools = create_chat_tools( + chat=harness.chat, + overrides={ + "postMessage": { + "description": "Reply in the active support thread", + "needs_approval": False, + }, + }, + ) + assert tools["postMessage"].description == "Reply in the active support thread" + assert tools["postMessage"].needs_approval is False + + async def test_overrides_cannot_replace_core_tool_fields(self, harness: _Harness): + # Stash sentinels; if any of these leak through, the tool can't run. + hijack_execute = AsyncMock(return_value={"hijacked": True}) + hijack_input_schema: dict[str, Any] = {"sentinel": "input"} + hijack_output_schema: dict[str, Any] = {"sentinel": "output"} + input_examples = [ + {"input": {"threadId": "slack:C123:1234.5678", "message": "hello"}}, + ] + metadata = {"source": "chat-sdk"} + + tools = create_chat_tools( + chat=harness.chat, + require_approval=False, + overrides={ + "postMessage": { + "args": {"name": "custom"}, + "description": "Reply in the active support thread", + "execute": hijack_execute, + "id": "openai.custom", + "input_examples": input_examples, + "input_schema": hijack_input_schema, + "metadata": metadata, + "output_schema": hijack_output_schema, + "supports_deferred_results": True, + "type": "provider", + }, + }, + ) + tool = tools["postMessage"] + + # Description does come from overrides... + assert tool.description == "Reply in the active support thread" + # ...but the protected fields are filtered out so the real tool is intact. + assert tool.execute is not hijack_execute + assert tool.input_schema is not hijack_input_schema + # `args`, `id`, `output_schema`, `supports_deferred_results`, and `type` + # are protected fields — they never make it into `extras`. + for protected in ( + "args", + "id", + "output_schema", + "supports_deferred_results", + "type", + ): + assert protected not in tool.extras, protected + # Non-protected fields pass through to `extras` for the agent runtime. + assert tool.extras["input_examples"] == input_examples + assert tool.extras["metadata"] == metadata + + # The real execute still dispatches to the adapter. + result = await tool.execute({"threadId": "slack:C123:1234.5678", "message": "hello"}) + hijack_execute.assert_not_awaited() + assert harness.adapter._post_calls == [("slack:C123:1234.5678", "hello")] + assert result == {"messageId": "msg-1", "threadId": "slack:C123:1234.5678"} + + +# --------------------------------------------------------------------------- +# Tool execute() paths +# --------------------------------------------------------------------------- + + +class TestExecutePaths: + """Each tool's ``execute()`` dispatches through to the right adapter call.""" + + async def test_post_message_dispatches_via_post_message(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["postMessage"].execute( + {"threadId": "slack:C123:1234.5678", "message": "hello"}, + ) + assert harness.adapter._post_calls == [("slack:C123:1234.5678", "hello")] + assert result["messageId"] == "msg-1" + + async def test_post_message_forwards_raw_postable_unchanged(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["postMessage"].execute( + { + "threadId": "slack:C123:1234.5678", + "message": {"raw": "..."}, + }, + ) + # The raw body must reach the adapter as a PostableRaw (not flattened to str). + assert len(harness.adapter._post_calls) == 1 + thread_id, sent = harness.adapter._post_calls[0] + assert thread_id == "slack:C123:1234.5678" + assert isinstance(sent, PostableRaw) + assert sent.raw == "..." + + async def test_post_channel_message_dispatches_with_markdown(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["postChannelMessage"].execute( + {"channelId": "slack:C123", "message": {"markdown": "**hi**"}}, + ) + # ChannelImpl uses post_channel_message on adapters that support it + # (which MockAdapter does), so this must produce a SentMessage. + assert result["messageId"] == "msg-1" + assert result["threadId"] == "slack:C123" + + async def test_send_direct_message_opens_dm_then_posts(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["sendDirectMessage"].execute( + {"userId": "U123456", "message": "ping"}, + ) + # MockAdapter.open_dm produces `slack:DU123456:` — the DM thread id. + assert harness.adapter._post_calls == [("slack:DU123456:", "ping")] + + async def test_add_reaction_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["addReaction"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "emoji": "thumbs_up", + }, + ) + assert harness.adapter._add_reaction_calls == [ + ("slack:C123:1234.5678", "msg-1", "thumbs_up"), + ] + assert result["added"] is True + assert result["emoji"] == "thumbs_up" + + async def test_remove_reaction_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["removeReaction"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "emoji": "thumbs_up", + }, + ) + assert harness.adapter._remove_reaction_calls == [ + ("slack:C123:1234.5678", "msg-1", "thumbs_up"), + ] + assert result == { + "removed": True, + "emoji": "thumbs_up", + "messageId": "msg-1", + "threadId": "slack:C123:1234.5678", + } + + async def test_delete_message_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["deleteMessage"].execute( + {"threadId": "slack:C123:1234.5678", "messageId": "msg-1"}, + ) + assert harness.adapter._delete_calls == [("slack:C123:1234.5678", "msg-1")] + assert result == { + "deleted": True, + "messageId": "msg-1", + "threadId": "slack:C123:1234.5678", + } + + async def test_edit_message_dispatches_with_markdown(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["editMessage"].execute( + { + "threadId": "slack:C123:1234.5678", + "messageId": "msg-1", + "message": {"markdown": "**updated**"}, + }, + ) + assert len(harness.adapter._edit_calls) == 1 + thread_id, msg_id, postable = harness.adapter._edit_calls[0] + assert thread_id == "slack:C123:1234.5678" + assert msg_id == "msg-1" + assert isinstance(postable, PostableMarkdown) + assert postable.markdown == "**updated**" + assert result["messageId"] == "msg-1" + + async def test_subscribe_thread_persists_subscription(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat, require_approval=False) + await tools["subscribeThread"].execute({"threadId": "slack:C123:1234.5678"}) + assert await harness.state.is_subscribed("slack:C123:1234.5678") is True + + async def test_unsubscribe_thread_clears_subscription(self, harness: _Harness): + # Seed the state so we can prove unsubscribe clears it. + await harness.state.subscribe("slack:C123:1234.5678") + assert await harness.state.is_subscribed("slack:C123:1234.5678") is True + + tools = create_chat_tools(chat=harness.chat, require_approval=False) + result = await tools["unsubscribeThread"].execute( + {"threadId": "slack:C123:1234.5678"}, + ) + assert await harness.state.is_subscribed("slack:C123:1234.5678") is False + assert result == {"subscribed": False, "threadId": "slack:C123:1234.5678"} + + async def test_start_typing_dispatches_via_adapter(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + await tools["startTyping"].execute( + {"threadId": "slack:C123:1234.5678", "status": "Searching..."}, + ) + assert harness.adapter._start_typing_calls == [ + ("slack:C123:1234.5678", "Searching..."), + ] + + async def test_fetch_messages_projects_model_friendly_shape(self, harness: _Harness): + stub_message = create_test_message("m1", "hello") + harness.adapter.fetch_messages = AsyncMock( # type: ignore[method-assign] + return_value=FetchResult(messages=[stub_message], next_cursor=None), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchMessages"].execute( + {"threadId": "slack:C123:1234.5678", "limit": 5, "direction": "backward"}, + ) + assert len(result["messages"]) == 1 + assert result["messages"][0]["id"] == "m1" + assert result["messages"][0]["text"] == "hello" + # Author is flattened into camelCase keys that match the wire shape. + assert result["messages"][0]["author"]["userName"] == "testuser" + + async def test_get_channel_info_returns_flattened_metadata(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + result = await tools["getChannelInfo"].execute({"channelId": "slack:C123"}) + assert result == { + "id": "slack:C123", + "name": "#slack:C123", + "isDM": False, + "memberCount": None, + "channelVisibility": None, + } + + async def test_fetch_channel_messages_dispatches_and_projects(self, harness: _Harness): + stub_message = create_test_message("m1", "channel hello") + harness.adapter.fetch_channel_messages = AsyncMock( # type: ignore[method-assign] + return_value=FetchResult(messages=[stub_message], next_cursor="next"), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchChannelMessages"].execute( + {"channelId": "slack:C123", "limit": 5, "direction": "backward"}, + ) + harness.adapter.fetch_channel_messages.assert_awaited_once() + call_args = harness.adapter.fetch_channel_messages.await_args + assert call_args.args[0] == "slack:C123" + # The FetchOptions are forwarded verbatim from the tool's inputs. + opts = call_args.args[1] + assert opts.limit == 5 + assert opts.cursor is None + assert opts.direction == "backward" + + assert len(result["messages"]) == 1 + assert result["messages"][0]["id"] == "m1" + assert result["messages"][0]["text"] == "channel hello" + assert result["nextCursor"] == "next" + + async def test_fetch_channel_messages_raises_when_adapter_unsupported(self, harness: _Harness): + # Remove the adapter's fetch_channel_messages so the tool path's + # "does not support" branch fires. + harness.adapter.fetch_channel_messages = None # type: ignore[method-assign,assignment] + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support fetching channel messages"): + await tools["fetchChannelMessages"].execute({"channelId": "slack:C123"}) + + async def test_fetch_channel_messages_wraps_not_implemented(self, harness: _Harness): + # BaseAdapter's default stub for optional methods raises + # ChatNotImplementedError. The tool must wrap that into ChatError so + # callers see one consistent failure mode, preserving the cause chain. + harness.adapter.fetch_channel_messages = AsyncMock( # type: ignore[method-assign] + side_effect=ChatNotImplementedError("slack", "fetch_channel_messages"), + ) + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support fetching channel messages") as exc_info: + await tools["fetchChannelMessages"].execute({"channelId": "slack:C123"}) + assert isinstance(exc_info.value.__cause__, ChatNotImplementedError) + + async def test_fetch_thread_returns_flattened_thread_info(self, harness: _Harness): + harness.adapter.fetch_thread = AsyncMock( # type: ignore[method-assign] + return_value=ThreadInfo( + id="slack:C123:1234.5678", + channel_id="C123", + channel_name="#general", + channel_visibility="public", + is_dm=False, + metadata={}, + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["fetchThread"].execute({"threadId": "slack:C123:1234.5678"}) + assert result == { + "id": "slack:C123:1234.5678", + "channelId": "C123", + "channelName": "#general", + "channelVisibility": "public", + "isDM": False, + } + + async def test_list_threads_projects_summaries(self, harness: _Harness): + root_message = create_test_message("m1", "root") + from datetime import datetime, timezone + + last_reply = datetime(2026, 3, 2, tzinfo=timezone.utc) + harness.adapter.list_threads = AsyncMock( # type: ignore[method-assign] + return_value=ListThreadsResult( + threads=[ + ThreadSummary( + id="slack:C123:1234.5678", + reply_count=4, + last_reply_at=last_reply, + root_message=root_message, + ), + ], + next_cursor=None, + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["listThreads"].execute( + {"channelId": "slack:C123", "limit": 10}, + ) + assert len(result["threads"]) == 1 + summary = result["threads"][0] + assert summary["id"] == "slack:C123:1234.5678" + assert summary["replyCount"] == 4 + assert summary["lastReplyAt"] == last_reply.isoformat() + assert summary["rootMessage"]["id"] == "m1" + assert summary["rootMessage"]["text"] == "root" + + async def test_list_threads_uses_keyword_options(self, harness: _Harness): + """Pin that the tool passes ``options`` as a keyword to ``list_threads``. + + ``MockAdapter.list_threads`` (and any adapter using a ``**kwargs`` + signature) rejects a second positional arg with ``TypeError`` — the + tool must use the keyword form. This exercises the **real** + ``MockAdapter.list_threads`` (no ``AsyncMock`` override) so a + regression to positional args trips immediately at runtime, not just + in the mock-adapter ergonomics. + """ + tools = create_chat_tools(chat=harness.chat) + result = await tools["listThreads"].execute({"channelId": "slack:C123"}) + assert result == {"threads": [], "nextCursor": None} + + async def test_list_threads_raises_when_adapter_unsupported(self, harness: _Harness): + harness.adapter.list_threads = None # type: ignore[method-assign,assignment] + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support listing threads"): + await tools["listThreads"].execute({"channelId": "slack:C123"}) + + async def test_list_threads_wraps_not_implemented(self, harness: _Harness): + harness.adapter.list_threads = AsyncMock( # type: ignore[method-assign] + side_effect=ChatNotImplementedError("slack", "list_threads"), + ) + tools = create_chat_tools(chat=harness.chat) + with pytest.raises(ChatError, match="does not support listing threads") as exc_info: + await tools["listThreads"].execute({"channelId": "slack:C123"}) + assert isinstance(exc_info.value.__cause__, ChatNotImplementedError) + + async def test_get_thread_participants_delegates_to_thread(self, harness: _Harness): + # Stub `chat.thread(...)` directly so we don't drag in the cursor + # pagination / current-message machinery just to test the projection. + from chat_sdk.types import Author as AuthorType + + participants_stub = [ + AuthorType( + user_id="UALICE1", + user_name="alice", + full_name="Alice", + is_bot=False, + is_me=False, + ), + AuthorType( + user_id="UBOB1", + user_name="bob", + full_name="Bob", + is_bot=False, + is_me=False, + ), + ] + + class _FakeThread: + async def get_participants(self) -> list[AuthorType]: + return participants_stub + + original_thread = harness.chat.thread + harness.chat.thread = lambda thread_id, **kwargs: _FakeThread() # type: ignore[assignment] + try: + tools = create_chat_tools(chat=harness.chat) + result = await tools["getThreadParticipants"].execute( + {"threadId": "slack:C123:1234.5678"}, + ) + finally: + harness.chat.thread = original_thread # type: ignore[assignment] + + assert result == { + "participants": [ + {"userId": "UALICE1", "userName": "alice", "fullName": "Alice", "isBot": False}, + {"userId": "UBOB1", "userName": "bob", "fullName": "Bob", "isBot": False}, + ], + } + + async def test_get_user_projects_user_info_when_found(self, harness: _Harness): + harness.adapter.get_user = AsyncMock( # type: ignore[method-assign] + return_value=UserInfo( + user_id="U123456", + user_name="alice", + full_name="Alice Doe", + email="alice@example.com", + is_bot=False, + avatar_url="https://example.com/a.png", + ), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["getUser"].execute({"userId": "U123456"}) + assert result == { + "userId": "U123456", + "userName": "alice", + "fullName": "Alice Doe", + "email": "alice@example.com", + "isBot": False, + "avatarUrl": "https://example.com/a.png", + } + + async def test_get_user_returns_none_when_adapter_returns_none(self, harness: _Harness): + harness.adapter.get_user = AsyncMock(return_value=None) # type: ignore[method-assign] + tools = create_chat_tools(chat=harness.chat) + result = await tools["getUser"].execute({"userId": "UMISSING"}) + assert result is None + + +# --------------------------------------------------------------------------- +# Schema sanity checks +# --------------------------------------------------------------------------- + + +class TestInputSchemas: + """Schemas are part of the public contract — break them, break agent runtimes.""" + + async def test_every_tool_declares_an_input_schema_with_a_description(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + for name, tool in tools.items(): + assert isinstance(tool, ChatTool), name + assert tool.description, f"{name} is missing a description" + assert tool.input_schema.get("type") == "object", name + assert "properties" in tool.input_schema, name + + async def test_postable_input_schema_is_a_oneof_union(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + message_schema = tools["postMessage"].input_schema["properties"]["message"] + # The union must include all three branches: string, markdown, raw. + assert "oneOf" in message_schema + kinds: list[Any] = [] + for branch in message_schema["oneOf"]: + if branch.get("type") == "string": + kinds.append("string") + elif "properties" in branch and "markdown" in branch["properties"]: + kinds.append("markdown") + elif "properties" in branch and "raw" in branch["properties"]: + kinds.append("raw") + assert sorted(kinds) == ["markdown", "raw", "string"] + + +# --------------------------------------------------------------------------- +# Re-exports +# --------------------------------------------------------------------------- + + +class TestReexports: + """The ``chat_sdk.ai`` package re-exports the tool factory surface.""" + + async def test_can_import_individual_factory(self, harness: _Harness): + # If individual tool factories aren't exported, downstream code that + # cherry-picks (``from chat_sdk.ai import post_message``) breaks. + from chat_sdk.ai import add_reaction, post_message + + tool = post_message(harness.chat) + assert isinstance(tool, ChatTool) + assert tool.needs_approval is True + # The needs_approval default propagates through the factory's ToolOptions. + + from chat_sdk.ai.tools import ToolOptions + + relaxed = add_reaction(harness.chat, ToolOptions(needs_approval=False)) + assert relaxed.needs_approval is False + + async def test_messages_helpers_still_importable_from_ai(self): + # PR 1 of the chat/ai port moved to_ai_messages here; if the new + # tool exports clobber that, the re-export goes silently stale. + from chat_sdk.ai import to_ai_messages + + assert callable(to_ai_messages) + + +# --------------------------------------------------------------------------- +# Approval gating quirks +# --------------------------------------------------------------------------- + + +class TestApprovalEdgeCases: + """Edge cases for the approval mapping that aren't covered above.""" + + async def test_partial_mapping_falls_back_to_true_for_unspecified_writes(self, harness: _Harness): + # Only override one tool — every other write tool should keep the + # default needs_approval=True. A regression that flips the default + # to False would silently let untrusted models post messages. + tools = create_chat_tools( + chat=harness.chat, + require_approval={"postMessage": False}, + ) + assert tools["postMessage"].needs_approval is False + # Every other write tool still needs approval. + for name in ( + "postChannelMessage", + "sendDirectMessage", + "editMessage", + "deleteMessage", + "addReaction", + "removeReaction", + "subscribeThread", + "unsubscribeThread", + ): + assert tools[name].needs_approval is True, name + + +# --------------------------------------------------------------------------- +# Channel info edge cases (covers ChannelInfo.is_dm branching) +# --------------------------------------------------------------------------- + + +class TestChannelInfoEdgeCases: + async def test_get_channel_info_defaults_is_dm_false_when_adapter_returns_none(self, harness: _Harness): + # ChannelInfo.is_dm is Optional; the tool must coerce None → False + # so the model sees a plain boolean instead of a missing field. + harness.adapter.fetch_channel_info = AsyncMock( # type: ignore[method-assign] + return_value=ChannelInfo(id="slack:C999", name=None, is_dm=None, metadata={}), + ) + tools = create_chat_tools(chat=harness.chat) + result = await tools["getChannelInfo"].execute({"channelId": "slack:C999"}) + assert result["isDM"] is False + assert result["name"] is None + + +# --------------------------------------------------------------------------- +# Schema isolation (review): tools must not share mutable nested schema dicts +# --------------------------------------------------------------------------- + + +class TestSchemaIsolation: + """Each tool must own a fully independent ``input_schema``. + + The factories build several tools from the same shared schema source + (the postable-message body, the fetch ``direction`` enum). If two tools + embedded the *same* nested dict object, a downstream consumer mutating + one tool's schema in place would silently corrupt its siblings. These + tests deep-mutate one tool's schema and assert the sibling is untouched, + pinning the per-tool deep-copy guarantee. + """ + + async def test_postable_message_schema_not_shared_between_tools(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + post_msg = tools["postMessage"] + post_channel = tools["postChannelMessage"] + + a = post_msg.input_schema["properties"]["message"] + b = post_channel.input_schema["properties"]["message"] + # Distinct top-level objects and distinct nested objects. + assert a is not b + assert a["oneOf"] is not b["oneOf"] + assert a["oneOf"][1] is not b["oneOf"][1] + + # Deep-mutate one tool's nested schema; the sibling must be unaffected. + a["oneOf"][1]["properties"]["markdown"]["description"] = "MUTATED" + a["oneOf"].append({"type": "null"}) + assert "description" not in b["oneOf"][1]["properties"]["markdown"] + assert len(b["oneOf"]) == 3 + + # A freshly built toolset must also be pristine (no module-level bleed). + fresh = create_chat_tools(chat=harness.chat) + fresh_msg = fresh["postMessage"].input_schema["properties"]["message"] + assert "description" not in fresh_msg["oneOf"][1]["properties"]["markdown"] + assert len(fresh_msg["oneOf"]) == 3 + + async def test_fetch_direction_schema_not_shared_between_tools(self, harness: _Harness): + tools = create_chat_tools(chat=harness.chat) + fetch_msgs = tools["fetchMessages"] + fetch_channel = tools["fetchChannelMessages"] + + a = fetch_msgs.input_schema["properties"]["direction"] + b = fetch_channel.input_schema["properties"]["direction"] + assert a is not b + # The enum list is a nested mutable object that must not be shared. + assert a["enum"] is not b["enum"] + + # Deep-mutate one tool's direction enum; sibling must be unaffected. + a["enum"].append("sideways") + assert b["enum"] == ["forward", "backward"] + + fresh = create_chat_tools(chat=harness.chat) + fresh_dir = fresh["fetchMessages"].input_schema["properties"]["direction"] + assert fresh_dir["enum"] == ["forward", "backward"]