diff --git a/plugins/Runner/claude-code-agent/README.md b/plugins/Runner/claude-code-agent/README.md index 1311c515e..b9e7ae143 100644 --- a/plugins/Runner/claude-code-agent/README.md +++ b/plugins/Runner/claude-code-agent/README.md @@ -70,13 +70,9 @@ Run Claude Code CLI as a LangBot Runner. The runner exposes a real MCP tool named `ask_user_question` through the run-scoped LangBot MCP bridge. Its standard Claude `tool_use` event is converted -to LangBot's provider-neutral `interaction.requested` contract. The CLI process -is stopped while the user is answering. A card submission starts a new -AgentRun, resumes the same Claude Code session, and sends the answer as the next -authoritative user turn. Claude CLI must complete an MCP call before persisting -a resumable session, so it cannot accept a second result for the old -`tool_use_id` after the process exits. No Python coroutine or Claude process is -kept waiting for the user. +to a single LangBot `request_interaction` call. The call waits for the user's +card submission and returns all form values to the MCP tool in the same AgentRun. +The CLI process remains active while the interaction is pending. ## Shared-worker candidate / daemon upgrade diff --git a/plugins/Runner/claude-code-agent/pkg/native_cli.py b/plugins/Runner/claude-code-agent/pkg/native_cli.py index ceb5584f8..d3d479692 100644 --- a/plugins/Runner/claude-code-agent/pkg/native_cli.py +++ b/plugins/Runner/claude-code-agent/pkg/native_cli.py @@ -21,7 +21,6 @@ from langbot_plugin.api.agent_tools.mcp_access import AgentRunMCPAccess from langbot_plugin.api.agent_tools.mcp_config import AgentMCPServerConfig from langbot_plugin.api.definition.components.runner.runner import Runner from langbot_plugin.api.entities.builtin.runner import ( - InteractionAction, InteractionField, InteractionOption, InteractionRequest, @@ -40,7 +39,6 @@ from pkg.runtime_support import local_workspace, terminate_process_group from pkg.steering import run_with_steering SESSION_STATE_KEY = "external.claude_code_session_id" -PENDING_INTERACTION_STATE_KEY = "external.claude_code_pending_interaction" SUPPORTED_LOCATIONS = {"local", "remote-ssh", "daemon"} @@ -353,7 +351,7 @@ def _interaction_from_claude_tool( title=title, description=(str(tool_input.get("description") or "").strip()[:10000] or None), fields=fields, - actions=[InteractionAction(id="submit", label="Submit", style="primary")], + actions=[], fallback_text=f"{title}: " + "; ".join(field.label for field in fields), ) continuation = { @@ -365,11 +363,6 @@ def _interaction_from_claude_tool( return request, continuation -def _pending_interaction(ctx: RunnerContext) -> dict[str, typing.Any] | None: - value = ctx.state.conversation.get(PENDING_INTERACTION_STATE_KEY) - return dict(value) if isinstance(value, dict) else None - - def _submission_payload( submission: InteractionSubmission, continuation: dict[str, typing.Any], @@ -393,14 +386,6 @@ def _submission_payload( } -def _interaction_resume_prompt(payload: dict[str, typing.Any]) -> str: - return ( - "The user answered the previously paused ask_user_question call. " - "Treat these values as the authoritative response and continue the interrupted task.\n" - + json.dumps(payload, ensure_ascii=False) - ) - - class ClaudeQuestionOption(pydantic.BaseModel): label: str = pydantic.Field(min_length=1, max_length=512) description: str | None = pydantic.Field(default=None, max_length=2000) @@ -455,27 +440,10 @@ class ClaudeCodeExternalTools(AgentRunExternalTools): request, continuation = _interaction_from_claude_tool( uuid.uuid4().hex, args.model_dump(mode="json", by_alias=True) ) - answers: list[dict[str, typing.Any]] = [] - for field in request.fields: - step = request.model_copy( - update={ - "interaction_id": f"{request.interaction_id}-{field.id}"[:255], - "fields": [field], - "actions": [], - } - ) - result = await self.ctx.request_interaction(step, timeout=3600, on_unsupported="degrade") - if result.status != "submitted" or result.submission is None: - raise NativeCliError(f"User interaction {result.status}", code="claude_code.interaction_expired") - answers.append( - { - "id": field.id, - "question": next( - (item["question"] for item in continuation["questions"] if item["id"] == field.id), field.label - ), - "answer": result.submission.values.get(field.id), - } - ) + result = await self.ctx.request_interaction(request, timeout=3600, on_unsupported="degrade") + if result.status != "submitted" or result.submission is None: + raise NativeCliError(f"User interaction {result.status}", code="claude_code.interaction_expired") + answers = _submission_payload(result.submission, continuation)["answers"] return {"status": "submitted", "answers": answers} @@ -576,31 +544,9 @@ class NativeClaudeCodeRunner(Runner): async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]: try: config = self._validate_config(ctx) - submission = ctx.input.interaction - if submission is not None: - continuation = _pending_interaction(ctx) - if continuation is None: - raise NativeCliError( - "pending Claude interaction state was not found", - code="claude_code.interaction_not_found", - ) - tool_use_id = str(continuation.get("tool_use_id") or "").strip() - if not tool_use_id: - raise NativeCliError( - "pending Claude interaction has no tool_use_id", - code="claude_code.interaction_invalid", - ) - prompt = _interaction_resume_prompt(_submission_payload(submission, continuation)) - yield RunnerResult.state_updated( - ctx.run_id, - PENDING_INTERACTION_STATE_KEY, - None, - scope="conversation", - ) - else: - prompt = _input_text(ctx) - if not prompt: - raise NativeCliError("input text is required", code="claude_code.empty_input") + prompt = _input_text(ctx) + if not prompt: + raise NativeCliError("input text is required", code="claude_code.empty_input") session_id, session_created = self._session_id(ctx, config) if session_created: yield RunnerResult.state_updated(ctx.run_id, SESSION_STATE_KEY, session_id, scope="conversation") @@ -876,7 +822,6 @@ async def _run_cli_process_events( deadline = time.monotonic() + timeout sequence = 0 final_parts: list[str] = [] - pending_interaction: tuple[InteractionRequest, dict[str, typing.Any]] | None = None terminal_result_text = "" stderr_task = asyncio.create_task(process.stderr.read()) try: @@ -910,15 +855,16 @@ async def _run_cli_process_events( "data": {"key": SESSION_STATE_KEY, "value": session_id, "scope": "conversation"}, } question_tool = _claude_question_tool_use(parsed) - if question_tool is not None and pending_interaction is None: - tool_use_id, tool_input = question_tool - pending_interaction = _interaction_from_claude_tool(tool_use_id, tool_input) - continue + if question_tool is not None: + raise NativeCliError( + "Use the LangBot ask_user_question MCP tool for user input", + code="claude_code.interaction_invalid", + ) chunk = _event_text(parsed) if parsed.get("type") == "result" and chunk: terminal_result_text = chunk continue - if chunk and pending_interaction is None: + if chunk: final_parts.append(chunk) if streaming: sequence += 1 @@ -940,24 +886,6 @@ async def _run_cli_process_events( raise NativeCliError( stderr or f"Claude Code exited with status {returncode}", code="claude_code.process_failed" ) - if pending_interaction is not None: - request, continuation = pending_interaction - yield { - "type": "state.updated", - "data": { - "key": PENDING_INTERACTION_STATE_KEY, - "value": continuation, - "scope": "conversation", - }, - } - yield { - "type": "action.requested", - "data": { - "action": "interaction.requested", - "payload": request.model_dump(mode="json"), - }, - } - return final_text = (terminal_result_text or "".join(final_parts)).strip() if not final_text: raise NativeCliError("Claude Code returned no assistant text", code="claude_code.empty_response") diff --git a/plugins/Runner/claude-code-agent/readme/README_en_US.md b/plugins/Runner/claude-code-agent/readme/README_en_US.md index 1311c515e..b9e7ae143 100644 --- a/plugins/Runner/claude-code-agent/readme/README_en_US.md +++ b/plugins/Runner/claude-code-agent/readme/README_en_US.md @@ -70,13 +70,9 @@ Run Claude Code CLI as a LangBot Runner. The runner exposes a real MCP tool named `ask_user_question` through the run-scoped LangBot MCP bridge. Its standard Claude `tool_use` event is converted -to LangBot's provider-neutral `interaction.requested` contract. The CLI process -is stopped while the user is answering. A card submission starts a new -AgentRun, resumes the same Claude Code session, and sends the answer as the next -authoritative user turn. Claude CLI must complete an MCP call before persisting -a resumable session, so it cannot accept a second result for the old -`tool_use_id` after the process exits. No Python coroutine or Claude process is -kept waiting for the user. +to a single LangBot `request_interaction` call. The call waits for the user's +card submission and returns all form values to the MCP tool in the same AgentRun. +The CLI process remains active while the interaction is pending. ## Shared-worker candidate / daemon upgrade diff --git a/plugins/Runner/codex-agent/README.md b/plugins/Runner/codex-agent/README.md index c8138fb08..c5cd3fe29 100644 --- a/plugins/Runner/codex-agent/README.md +++ b/plugins/Runner/codex-agent/README.md @@ -66,15 +66,12 @@ Run Codex CLI as a LangBot Runner. The runner registers a real Codex app-server dynamic tool named `ask_user_question` and also accepts Codex's native `item/tool/requestUserInput` request. Both are converted to LangBot's -provider-neutral `interaction.requested` contract, so Lark, DingTalk, and other +provider-neutral `request_interaction` call, so Lark, DingTalk, and other delivery adapters can render the same fields and actions. -The app-server process is stopped while the user is answering. The submission -starts a new AgentRun and resumes the same Codex thread. Codex app-server does -not support replying to an old JSON-RPC tool request from a new process, so the -answer is delivered as the next authoritative user turn rather than injected -as the old tool result. This is a provider transport limitation; the interaction -is still a real model tool call and no process is kept waiting. +The call waits for the user's submission and returns all form values to the +app-server tool in the same AgentRun. The app-server process remains active +while the interaction is pending. Codex app-server command-execution and file-change approval requests are also converted to action-only confirmation cards. The previous turn is cancelled diff --git a/plugins/Runner/codex-agent/pkg/native_cli.py b/plugins/Runner/codex-agent/pkg/native_cli.py index a7378cc0e..a8584355c 100644 --- a/plugins/Runner/codex-agent/pkg/native_cli.py +++ b/plugins/Runner/codex-agent/pkg/native_cli.py @@ -40,7 +40,6 @@ from pkg.runtime_support import local_workspace, serialize_codex_session, termin from pkg.steering import run_with_steering SESSION_STATE_KEY = "external.codex_session_id" -PENDING_INTERACTION_STATE_KEY = "external.codex_pending_interaction" SUPPORTED_LOCATIONS = {"local", "remote-ssh", "daemon"} DEFAULT_TIMEOUT_SECONDS = 1800.0 CODEX_STDIO_LIMIT_BYTES = 16 * 1024 * 1024 @@ -523,7 +522,7 @@ def _interaction_from_questions( title=request_title, description=description.strip()[:10000] or None, fields=fields, - actions=[InteractionAction(id="submit", label="Submit", style="primary")], + actions=[], fallback_text=f"{request_title}: " + "; ".join(field.label for field in fields), ) continuation = { @@ -568,11 +567,6 @@ def _interaction_from_codex_request( return request, continuation -def _pending_interaction(ctx: RunnerContext) -> dict[str, typing.Any] | None: - value = ctx.state.conversation.get(PENDING_INTERACTION_STATE_KEY) - return dict(value) if isinstance(value, dict) else None - - def _approval_item(params: dict[str, typing.Any], items: dict[str, dict[str, typing.Any]]) -> dict[str, typing.Any]: item_id = str(params.get("itemId") or params.get("callId") or "") item = items.get(item_id) @@ -695,61 +689,6 @@ def _interaction_from_codex_approval( return request, continuation -def _interaction_resume_prompt( - submission: InteractionSubmission, - continuation: dict[str, typing.Any], -) -> str: - if submission.interaction_id != continuation.get("interaction_id"): - raise NativeCliError( - "interaction submission does not match the pending Codex request", code="codex.interaction_mismatch" - ) - question_by_id = { - str(item.get("id")): str(item.get("question") or item.get("id")) - for item in continuation.get("questions", []) - if isinstance(item, dict) and item.get("id") - } - answers = [ - {"id": field_id, "question": question_by_id.get(field_id, field_id), "answer": value} - for field_id, value in submission.values.items() - ] - payload = {"action": submission.action_id, "answers": answers} - return ( - "The user answered the previously paused ask_user_question call. " - "Treat these values as the authoritative response and continue the interrupted task.\n" - + json.dumps(payload, ensure_ascii=False) - ) - - -def _approval_resume( - submission: InteractionSubmission, - continuation: dict[str, typing.Any], -) -> tuple[str, dict[str, str] | None]: - if submission.interaction_id != continuation.get("interaction_id"): - raise NativeCliError( - "interaction submission does not match the pending Codex approval", code="codex.interaction_mismatch" - ) - action_id = str(submission.action_id or "") - summary = str(continuation.get("approval_summary") or "") - if action_id == APPROVE_ONCE_ACTION: - category = str(continuation.get("approval_category") or "") - fingerprint = str(continuation.get("approval_fingerprint") or "") - if not category or not fingerprint: - raise NativeCliError("pending Codex approval has no operation fingerprint", code="codex.approval_invalid") - prompt = ( - "The user approved exactly one retry of the previously paused Codex operation. " - "Retry that same operation now. This approval does not authorize any different command or file change.\n" - + summary - ) - return prompt, {"category": category, "fingerprint": fingerprint} - if action_id == REJECT_ACTION: - prompt = ( - "The user rejected the previously paused Codex operation. Do not retry that exact operation. " - "Continue with a safe alternative or explain why the task cannot proceed.\n" + summary - ) - return prompt, None - raise NativeCliError(f"unsupported Codex approval action: {action_id}", code="codex.approval_invalid") - - class NativeCodexRunner(Runner): def _validate_config(self, ctx: RunnerContext) -> dict[str, typing.Any]: data = ctx.config or {} @@ -810,8 +749,6 @@ class NativeCodexRunner(Runner): stored = self._stored_session_id(ctx) if not stored or not config["reuse_session"]: return "" - if ctx.input.interaction is not None and _pending_interaction(ctx) is not None: - return stored if config["location"] == "local": env = {**os.environ, **config["env"]} pending_calls = _pending_session_tool_calls(_shared_codex_home(env), stored) @@ -847,53 +784,14 @@ class NativeCodexRunner(Runner): async def run(self, ctx: RunnerContext) -> typing.AsyncGenerator[RunnerResult, None]: try: config = self._validate_config(ctx) - submission = ctx.input.interaction - approval_grant: dict[str, str] | None = None - if submission is not None: - continuation = _pending_interaction(ctx) - if continuation is None: - raise NativeCliError( - "pending Codex interaction state was not found", code="codex.interaction_not_found" - ) - if continuation.get("kind") == "approval": - prompt, approval_grant = _approval_resume(submission, continuation) - else: - prompt = _interaction_resume_prompt(submission, continuation) - yield RunnerResult.state_updated( - ctx.run_id, - PENDING_INTERACTION_STATE_KEY, - None, - scope="conversation", - ) - else: - prompt = _input_text(ctx) - if not prompt: - raise NativeCliError("input text is required", code="codex.empty_input") - - approval_grant_holder = {"value": approval_grant} + prompt = _input_text(ctx) + if not prompt: + raise NativeCliError("input text is required", code="codex.empty_input") def run_turn(turn_prompt: str, resume_session_id: str) -> typing.AsyncGenerator[RunnerResult, None]: - turn_approval_grant = approval_grant_holder["value"] - approval_grant_holder["value"] = None if config["location"] == "daemon": - if turn_approval_grant is None: - return self._run_daemon(ctx, config, turn_prompt, resume_session_id) - return self._run_daemon( - ctx, - config, - turn_prompt, - resume_session_id, - approval_grant=turn_approval_grant, - ) - if turn_approval_grant is None: - return self._run_local_or_ssh(ctx, config, turn_prompt, resume_session_id) - return self._run_local_or_ssh( - ctx, - config, - turn_prompt, - resume_session_id, - approval_grant=turn_approval_grant, - ) + return self._run_daemon(ctx, config, turn_prompt, resume_session_id) + return self._run_local_or_ssh(ctx, config, turn_prompt, resume_session_id) async for result in run_with_steering( ctx, @@ -1182,8 +1080,6 @@ class _CodexAppServerClient: self.final_error = "" self.seen_agent_message_item_ids: set[str] = set() self.final_chunk_emitted = False - self.interaction_request: InteractionRequest | None = None - self.interaction_continuation: dict[str, typing.Any] | None = None self.approval_grant = dict(approval_grant) if approval_grant else None self.approval_policy = approval_policy self.ctx = ctx @@ -1198,25 +1094,10 @@ class _CodexAppServerClient: if self.timeout_scope is not None: self.timeout_scope.reschedule(loop.time() + 3600 + self.operation_timeout) try: - if request.actions and not request.fields: - result = await self.ctx.request_interaction(request, timeout=3600, on_unsupported="degrade") - if result.status != "submitted" or result.submission is None: - raise NativeCliError(f"User interaction {result.status}", code="codex.interaction_expired") - return result.submission - values: dict[str, typing.Any] = {} - for field in request.fields: - step = request.model_copy( - update={ - "interaction_id": f"{request.interaction_id}-{field.id}"[:255], - "fields": [field], - "actions": [], - } - ) - result = await self.ctx.request_interaction(step, timeout=3600, on_unsupported="degrade") - if result.status != "submitted" or result.submission is None: - raise NativeCliError(f"User interaction {result.status}", code="codex.interaction_expired") - values[field.id] = result.submission.values.get(field.id) - return InteractionSubmission(interaction_id=request.interaction_id, values=values) + result = await self.ctx.request_interaction(request, timeout=3600, on_unsupported="degrade") + if result.status != "submitted" or result.submission is None: + raise NativeCliError(f"User interaction {result.status}", code="codex.interaction_expired") + return result.submission finally: if self.timeout_scope is not None: self.timeout_scope.reschedule(loop.time() + self.operation_timeout) @@ -1399,18 +1280,11 @@ class _CodexAppServerClient: self.approval_grant = None await self.respond(request_id, {"decision": "accept"}) return - request, continuation = _interaction_from_codex_approval(method, params, item) - if self.ctx is not None: - submission = await self._wait_for_user(request) - await self.respond( - request_id, {"decision": "accept" if submission.action_id == APPROVE_ONCE_ACTION else "cancel"} - ) - return - self.interaction_request = request - self.interaction_continuation = continuation - await self.respond(request_id, {"decision": "cancel"}) - await self._interrupt_turn(params) - self.finish_turn() + request, _ = _interaction_from_codex_approval(method, params, item) + submission = await self._wait_for_user(request) + await self.respond( + request_id, {"decision": "accept" if submission.action_id == APPROVE_ONCE_ACTION else "cancel"} + ) elif method in {"item/tool/call", "item/tool/requestUserInput"}: params = raw.get("params") if not isinstance(params, dict): @@ -1419,73 +1293,36 @@ class _CodexAppServerClient: await self.respond_error(request_id, f"unhandled dynamic tool: {params.get('tool')}") return try: - request, continuation = _interaction_from_codex_request(method, params) + request, _ = _interaction_from_codex_request(method, params) except NativeCliError as exc: await self.respond_error(request_id, exc.message) return - if self.ctx is not None: - submission = await self._wait_for_user(request) - if method == "item/tool/call": - await self.respond( - request_id, - { - "success": True, - "contentItems": [ - {"type": "inputText", "text": json.dumps(submission.values, ensure_ascii=False)} - ], - }, - ) - else: - await self.respond( - request_id, - { - "answers": { - field_id: {"answers": value if isinstance(value, list) else [str(value)]} - for field_id, value in submission.values.items() - } - }, - ) - return - self.interaction_request = request - self.interaction_continuation = continuation + submission = await self._wait_for_user(request) if method == "item/tool/call": await self.respond( request_id, { "success": True, "contentItems": [ - { - "type": "inputText", - "text": "LangBot paused this run for user input; the answer will arrive in a later turn.", - } + {"type": "inputText", "text": json.dumps(submission.values, ensure_ascii=False)} ], }, ) else: - empty_answers = {field.id: {"answers": []} for field in request.fields} - await self.respond(request_id, {"answers": empty_answers}) - await self._interrupt_turn(params) - self.finish_turn() + await self.respond( + request_id, + { + "answers": { + field_id: {"answers": value if isinstance(value, list) else [str(value)]} + for field_id, value in submission.values.items() + } + }, + ) elif method == "mcpServer/elicitation/request": await self.respond(request_id, {"action": "accept", "content": None, "_meta": None}) else: await self.respond_error(request_id, f"unhandled server request: {method}") - async def _interrupt_turn(self, params: dict[str, typing.Any]) -> None: - thread_id = str(params.get("threadId") or self.thread_id) - turn_id = str(params.get("turnId") or "") - if not thread_id or not turn_id: - return - self.next_id += 1 - await self._write_json( - { - "jsonrpc": "2.0", - "id": self.next_id, - "method": "turn/interrupt", - "params": {"threadId": thread_id, "turnId": turn_id}, - } - ) - async def handle_notification(self, raw: dict[str, typing.Any]) -> None: method = str(raw.get("method") or "") params = raw.get("params") @@ -1701,27 +1538,6 @@ async def _run_cli_process_events( raise NativeCliError( stderr or f"Codex app-server exited with status {process.returncode}", code="codex.process_failed" ) - if client.interaction_request is not None and client.interaction_continuation is not None: - result_sequence += 1 - yield { - "type": "state.updated", - "sequence": result_sequence, - "data": { - "key": PENDING_INTERACTION_STATE_KEY, - "value": client.interaction_continuation, - "scope": "conversation", - }, - } - result_sequence += 1 - yield { - "type": "action.requested", - "sequence": result_sequence, - "data": { - "action": "interaction.requested", - "payload": client.interaction_request.model_dump(mode="json"), - }, - } - return if client.final_error: raise NativeCliError(client.final_error, code="codex.turn_failed") final_text = "".join(client.final_parts).strip() diff --git a/plugins/Runner/codex-agent/readme/README_en_US.md b/plugins/Runner/codex-agent/readme/README_en_US.md index c8138fb08..c5cd3fe29 100644 --- a/plugins/Runner/codex-agent/readme/README_en_US.md +++ b/plugins/Runner/codex-agent/readme/README_en_US.md @@ -66,15 +66,12 @@ Run Codex CLI as a LangBot Runner. The runner registers a real Codex app-server dynamic tool named `ask_user_question` and also accepts Codex's native `item/tool/requestUserInput` request. Both are converted to LangBot's -provider-neutral `interaction.requested` contract, so Lark, DingTalk, and other +provider-neutral `request_interaction` call, so Lark, DingTalk, and other delivery adapters can render the same fields and actions. -The app-server process is stopped while the user is answering. The submission -starts a new AgentRun and resumes the same Codex thread. Codex app-server does -not support replying to an old JSON-RPC tool request from a new process, so the -answer is delivered as the next authoritative user turn rather than injected -as the old tool result. This is a provider transport limitation; the interaction -is still a real model tool call and no process is kept waiting. +The call waits for the user's submission and returns all form values to the +app-server tool in the same AgentRun. The app-server process remains active +while the interaction is pending. Codex app-server command-execution and file-change approval requests are also converted to action-only confirmation cards. The previous turn is cancelled diff --git a/plugins/Runner/dify-agent/components/runner/default.py b/plugins/Runner/dify-agent/components/runner/default.py index 0a835c344..30068c412 100644 --- a/plugins/Runner/dify-agent/components/runner/default.py +++ b/plugins/Runner/dify-agent/components/runner/default.py @@ -6,14 +6,11 @@ Real Dify Service API integration supporting chat, agent, and workflow app types from __future__ import annotations import base64 -import hashlib import json import logging import re -import time import typing import uuid -from contextlib import aclosing from langbot_plugin.api.definition.components.runner.runner import Runner from langbot_plugin.api.entities.builtin.provider.message import MessageChunk @@ -25,7 +22,6 @@ from langbot_plugin.api.entities.builtin.runner import ( RunnerContext, RunnerResult, ) -from langbot_plugin.entities.io.errors import ActionCallError from pkg.asset_gateway import register_assets from pkg.dify_client import ( AsyncDifyClient, @@ -39,7 +35,6 @@ from pkg.scoped_identity import scoped_identity logger = logging.getLogger(__name__) DEFAULT_LANGBOT_ASSET_TOKEN_INPUT = "langbot_asset_run_token" -INTERACTION_STORAGE_PREFIX = "dify.interactions." def _to_bool(value: typing.Any, default: bool = False) -> bool: @@ -364,10 +359,6 @@ def _form_step_descriptions( return descriptions, action_description -def _interaction_storage_key(interaction_id: str) -> str: - return f"{INTERACTION_STORAGE_PREFIX}{interaction_id}.json" - - class DefaultRunner(Runner): """Real Runner for Dify Service API. @@ -597,129 +588,17 @@ class DefaultRunner(Runner): return base_prompt return text - async def _store_interaction_continuation( - self, - ctx: RunnerContext, - continuation: dict[str, typing.Any], - ) -> None: - interaction_id = str(continuation["interaction_id"]) - continuation = dict(continuation, owner=self._continuation_owner(ctx)) - continuation.setdefault("expires_at", time.time() + 3600) - payload = json.dumps(continuation, ensure_ascii=False, separators=(",", ":")).encode("utf-8") - await self.get_run_api(ctx).set_plugin_storage(_interaction_storage_key(interaction_id), payload) - - async def _load_interaction_continuation( - self, - ctx: RunnerContext, - interaction_id: str, - ) -> dict[str, typing.Any]: - key = _interaction_storage_key(interaction_id) - try: - payload = await self.get_run_api(ctx).get_plugin_storage(key) - except (KeyError, ActionCallError) as exc: - if isinstance(exc, ActionCallError): - message = str(exc) - while message.startswith("ActionCallError: "): - message = message.removeprefix("ActionCallError: ") - if message != f"Storage with key {key} not found": - raise - raise DifyAPIError( - "The Dify human-input request is no longer pending", - code="dify.interaction_not_found", - ) from exc - continuation = json.loads(payload.decode("utf-8")) - if not isinstance(continuation, dict) or continuation.get("interaction_id") != interaction_id: - raise DifyAPIError( - "The Dify human-input continuation is invalid", - code="dify.interaction_invalid", - ) - if continuation.get("version") != 1: - raise DifyAPIError( - "The Dify human-input continuation version is unsupported", - code="dify.interaction_invalid", - ) - if continuation.get("consumed") or continuation.get("expires_at", 0) < time.time(): - raise DifyAPIError("Dify continuation expired or already consumed", code="dify.interaction_not_found") - owner = self._continuation_owner(ctx) - stored_owner = continuation.get("owner") - if stored_owner != owner: - if stored_owner != self._legacy_continuation_owner(ctx): - raise DifyAPIError("Dify continuation belongs to another context", code="dify.interaction_invalid") - # One-time migration: a continuation stored before identity resolution moved - # to task-local bindings is accepted and re-written with the current owner. - # No long-term dual-rule compatibility branch beyond this upgrade path. - continuation = dict(continuation, owner=owner) - return continuation - - def _continuation_subject(self, ctx): - conversation = ctx.conversation - return json.dumps( - [ - getattr(conversation, "conversation_id", None), - getattr(conversation, "launcher_type", None), - getattr(conversation, "launcher_id", None), - getattr(ctx.actor, "actor_id", None), - (ctx.config or {}).get("base-url"), - ], - separators=(",", ":"), - ) - - def _continuation_owner(self, ctx): - return scoped_identity(self, ctx, self._continuation_subject(ctx)) - - def _legacy_continuation_owner(self, ctx): - """Owner computed before identity resolution moved to task-local bindings. - - Used only to accept continuations persisted by the previous release so an - in-flight human-input request survives the upgrade; the record is re-written - with the current owner on the next store. - """ - binding = getattr(getattr(self, "_plugin_runtime_handler", None), "bound_action_context", None) - subject = self._continuation_subject(ctx) - if binding is not None: - scope = [binding.instance_uuid, binding.workspace_uuid, binding.installation_uuid] - else: - workspace = getattr(ctx.conversation, "workspace_id", None) - if not workspace: - return subject - scope = [workspace] - encoded = json.dumps([scope, subject], ensure_ascii=False, separators=(",", ":")).encode() - return "lb_" + hashlib.sha256(encoded).hexdigest() - - async def _delete_interaction_continuation(self, ctx: RunnerContext, interaction_id: str) -> None: - await self.get_run_api(ctx).delete_plugin_storage(_interaction_storage_key(interaction_id)) - @staticmethod def _request_from_continuation(continuation: dict[str, typing.Any]) -> InteractionRequest: fields = [InteractionField.model_validate(item) for item in continuation.get("current_fields") or []] actions = [InteractionAction.model_validate(item) for item in continuation.get("interaction_actions") or []] - if continuation.get("phase") == "field": + if fields: actions = [] kind = ( "confirmation" if actions and not fields else "choice" if fields and fields[0].type == "select" else "form" ) - if "field_descriptions" in continuation: - field_descriptions = ( - continuation.get("field_descriptions") - if isinstance(continuation.get("field_descriptions"), dict) - else {} - ) - description = ( - field_descriptions.get(fields[0].id) - if continuation.get("phase") == "field" and fields - else continuation.get("action_description") - ) - else: - description = continuation.get("description") + description = continuation.get("description") fallback_text = str(continuation.get("fallback_text") or "Human input is required.") - if fields: - field = fields[0] - field_lines = [str(continuation.get("title") or "Human input required"), field.label] - if description: - field_lines.insert(1, str(description)) - if field.options: - field_lines.append("Options: " + ", ".join(option.label for option in field.options)) - fallback_text = "\n".join(field_lines) return InteractionRequest( interaction_id=str(continuation["interaction_id"]), kind=kind, @@ -730,46 +609,6 @@ class DefaultRunner(Runner): fallback_text=fallback_text, ) - async def _advance_field_interaction( - self, - ctx: RunnerContext, - continuation: dict[str, typing.Any], - submission: typing.Any, - ) -> RunnerResult: - field_map = continuation.get("field_map") if isinstance(continuation.get("field_map"), dict) else {} - inputs = dict(continuation.get("default_inputs") or {}) - current_fields = [InteractionField.model_validate(item) for item in continuation.get("current_fields") or []] - for field_id, value in submission.values.items(): - provider_name = field_map.get(field_id) - if provider_name: - inputs[str(provider_name)] = value - for field in current_fields: - provider_name = field_map.get(field.id) - if field.required and provider_name and inputs.get(str(provider_name)) in (None, "", []): - raise DifyAPIError( - f"Dify human-input field is required: {field.label}", - code="dify.interaction_invalid", - ) - - old_interaction_id = str(continuation["interaction_id"]) - remaining_fields = list(continuation.get("remaining_fields") or []) - continuation = dict(continuation) - continuation["interaction_id"] = f"dify-{uuid.uuid4().hex}" - continuation["default_inputs"] = inputs - if remaining_fields: - continuation["phase"] = "field" - continuation["current_fields"] = [remaining_fields.pop(0)] - continuation["remaining_fields"] = remaining_fields - else: - continuation["phase"] = "action" - continuation["current_fields"] = [] - continuation["remaining_fields"] = [] - - request = self._request_from_continuation(continuation) - await self._store_interaction_continuation(ctx, continuation) - await self._delete_interaction_continuation(ctx, old_interaction_id) - return RunnerResult.interaction_requested(ctx.run_id, request) - def _build_interaction_request( self, reason: dict[str, typing.Any], @@ -885,28 +724,24 @@ class DefaultRunner(Runner): "field_descriptions": field_descriptions, "action_description": action_description, "fallback_text": "\n".join(fallback_lines), - "phase": "field" if fields else "action", - "current_fields": [fields[0].model_dump(mode="json")] if fields else [], - "remaining_fields": [field.model_dump(mode="json") for field in fields[1:]], + "phase": "action", + "current_fields": [field.model_dump(mode="json") for field in fields], + "remaining_fields": [], "interaction_actions": [action.model_dump(mode="json") for action in actions], } + if fields and len(actions) > 1: + continuation["current_fields"].append( + InteractionField( + id="_dify_action", + label="Action", + type="select", + required=True, + options=[InteractionOption(value=action.id, label=action.label) for action in actions], + ).model_dump(mode="json") + ) request = self._request_from_continuation(continuation) return request, continuation - async def _interaction_result_for_pause( - self, - ctx: RunnerContext, - event: dict[str, typing.Any], - user: str, - ) -> RunnerResult | None: - workflow_run_id = _workflow_run_id(event) - reason = _human_input_reason(event) - if reason is None: - return None - request, continuation = self._build_interaction_request(reason, workflow_run_id, user) - await self._store_interaction_continuation(ctx, continuation) - return RunnerResult.interaction_requested(ctx.run_id, request) - async def _wait_and_resume_workflow( self, ctx: RunnerContext, @@ -918,31 +753,7 @@ class DefaultRunner(Runner): reason = _human_input_reason(event) if reason is None: raise DifyAPIError("Dify paused without a supported human-input reason", code="dify.response_invalid") - _, continuation = self._build_interaction_request(reason, _workflow_run_id(event), user) - while continuation["phase"] == "field": - request = self._request_from_continuation(continuation) - result = await ctx.request_interaction(request, timeout=3600, on_unsupported="degrade") - if result.status != "submitted" or result.submission is None: - raise DifyAPIError(f"Dify human input {result.status}", code="dify.interaction_expired") - field_map = continuation["field_map"] - inputs = continuation["default_inputs"] - for field_id, value in result.submission.values.items(): - if field_id in field_map: - inputs[field_map[field_id]] = value - current_field = InteractionField.model_validate(continuation["current_fields"][0]) - provider_name = field_map.get(current_field.id) - if current_field.required and provider_name and inputs.get(provider_name) in (None, "", []): - raise DifyAPIError( - f"Dify human-input field is required: {current_field.label}", code="dify.interaction_invalid" - ) - remaining = continuation["remaining_fields"] - continuation["interaction_id"] = f"dify-{uuid.uuid4().hex}" - if remaining: - continuation["current_fields"] = [remaining.pop(0)] - else: - continuation["phase"] = "action" - continuation["current_fields"] = [] - request = self._request_from_continuation(continuation) + request, continuation = self._build_interaction_request(reason, _workflow_run_id(event), user) result = await ctx.request_interaction(request, timeout=3600, on_unsupported="degrade") if result.status != "submitted" or result.submission is None: raise DifyAPIError(f"Dify human input {result.status}", code="dify.interaction_expired") @@ -952,7 +763,6 @@ class DefaultRunner(Runner): result.submission, remove_think, continuation=continuation, - blocking=True, ): yield item @@ -983,21 +793,6 @@ class DefaultRunner(Runner): asset_registration = None try: - if ctx.event.event_type == "interaction.submitted": - if ctx.input.interaction is None: - raise DifyAPIError( - "interaction.submitted event is missing its validated submission", - code="dify.interaction_invalid", - ) - async for result in self._resume_workflow(ctx, client, ctx.input.interaction, remove_think): - yield result - return - if ctx.input.interaction is not None: - raise DifyAPIError( - "Interaction submission is only valid for interaction.submitted events", - code="dify.interaction_invalid", - ) - # Upload files if present. Multimodal inputs must not silently # degrade to text-only when the provider upload path fails. files = await self._upload_input_files(ctx, client, user) @@ -1436,45 +1231,14 @@ class DefaultRunner(Runner): yield RunnerResult.run_completed(ctx.run_id, usage=usage) - async def _resume_workflow(self, ctx, client, submission, remove_think): - active = getattr(self, "_active_resumes", None) - if active is None: - active = self._active_resumes = set() - from langbot_plugin.api.proxies.invocation import current_binding - - handler = getattr(self, "_plugin_runtime_handler", None) - # Same binding chain as scoped_identity, so the owner and the concurrency - # key can never disagree within one invocation. - binding = current_binding(handler) if handler is not None else None - if binding is None: - binding = getattr(handler, "bound_action_context", None) - key = (binding, self._continuation_owner(ctx), str(submission.interaction_id)) - if key in active: - raise DifyAPIError("Dify continuation is already being resumed", code="dify.interaction_invalid") - active.add(key) - try: - async with aclosing(self._resume_workflow_once(ctx, client, submission, remove_think)) as stream: - async for result in stream: - yield result - finally: - active.remove(key) - async def _resume_workflow_once( self, ctx: RunnerContext, client: AsyncDifyClient, submission: typing.Any, remove_think: bool, - continuation: dict[str, typing.Any] | None = None, - blocking: bool = False, + continuation: dict[str, typing.Any], ) -> typing.AsyncGenerator[RunnerResult, None]: - interaction_id = str(submission.interaction_id) - continuation = ( - continuation if continuation is not None else await self._load_interaction_continuation(ctx, interaction_id) - ) - if continuation.get("phase") == "field": - yield await self._advance_field_interaction(ctx, continuation, submission) - return field_map = continuation.get("field_map") if isinstance(continuation.get("field_map"), dict) else {} action_map = continuation.get("action_map") if isinstance(continuation.get("action_map"), dict) else {} inputs = dict(continuation.get("default_inputs") or {}) @@ -1482,19 +1246,15 @@ class DefaultRunner(Runner): provider_name = field_map.get(field_id) if provider_name: inputs[str(provider_name)] = value - action_id = submission.action_id or "" + action_id = submission.action_id or submission.values.get("_dify_action") or "" + if not action_id and len(action_map) == 1: + action_id = next(iter(action_map)) if action_id not in action_map: raise DifyAPIError( "Dify human-input action is missing or invalid", code="dify.interaction_invalid", ) action = action_map[action_id] - # Persist consumption BEFORE the external effect. An ambiguous failure - # remains consumed, never silently retries an approval at the vendor. - continuation["consumed"] = True - if not blocking: - await self._store_interaction_continuation(ctx, continuation) - pending_content = "" streamed_content = "" text_event_count = 0 @@ -1570,28 +1330,14 @@ class DefaultRunner(Runner): **data, "workflow_run_id": str(continuation["workflow_run_id"]), } - if blocking: - async for result in self._wait_and_resume_workflow( - ctx, - client, - event, - str(continuation["user"]), - remove_think, - ): - yield result - else: - interaction_result = await self._interaction_result_for_pause( - ctx, - event, - str(continuation["user"]), - ) - if interaction_result is None: - raise DifyAPIError( - "Dify paused the workflow without a supported reason", - code="dify.response_invalid", - ) - await self._delete_interaction_continuation(ctx, interaction_id) - yield interaction_result + async for result in self._wait_and_resume_workflow( + ctx, + client, + event, + str(continuation["user"]), + remove_think, + ): + yield result return elif event_type == "workflow_finished": data = event.get("data", {}) @@ -1628,6 +1374,4 @@ class DefaultRunner(Runner): "Dify workflow returned no response after human input", code="dify.api_error", ) - if not blocking: - await self._delete_interaction_continuation(ctx, interaction_id) yield RunnerResult.run_completed(ctx.run_id, usage=usage) diff --git a/plugins/Runner/dify-agent/tests/test_missing_continuation.py b/plugins/Runner/dify-agent/tests/test_missing_continuation.py deleted file mode 100644 index 54e0dfdc2..000000000 --- a/plugins/Runner/dify-agent/tests/test_missing_continuation.py +++ /dev/null @@ -1,46 +0,0 @@ -import json -from types import SimpleNamespace -from unittest.mock import AsyncMock - -import pytest -from components.runner.default import DefaultRunner, _interaction_storage_key -from langbot_plugin.entities.io.errors import ActionCallError -from pkg.dify_client import DifyAPIError - - -@pytest.mark.asyncio -@pytest.mark.parametrize("prefix", ["", "ActionCallError: ", "ActionCallError: ActionCallError: "]) -async def test_missing_rpc_key(prefix): - key = _interaction_storage_key("missing") - api = SimpleNamespace( - get_plugin_storage=AsyncMock(side_effect=ActionCallError(prefix + f"Storage with key {key} not found")) - ) - runner = SimpleNamespace(get_run_api=lambda ctx: api) - with pytest.raises(DifyAPIError) as exc: - await DefaultRunner._load_interaction_continuation(runner, None, "missing") - assert exc.value.code == "dify.interaction_not_found" - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - "error", - [ - ActionCallError("permission denied"), - ActionCallError("Storage with key other not found"), - TimeoutError("timeout"), - ], -) -async def test_other_errors_propagate(error): - runner = SimpleNamespace(get_run_api=lambda ctx: SimpleNamespace(get_plugin_storage=AsyncMock(side_effect=error))) - with pytest.raises(type(error)) as exc: - await DefaultRunner._load_interaction_continuation(runner, None, "missing") - assert exc.value is error - - -@pytest.mark.asyncio -async def test_corrupt_json_propagates(): - runner = SimpleNamespace( - get_run_api=lambda ctx: SimpleNamespace(get_plugin_storage=AsyncMock(return_value=b"broken")) - ) - with pytest.raises(json.JSONDecodeError): - await DefaultRunner._load_interaction_continuation(runner, None, "missing") diff --git a/plugins/Runner/dify-agent/tests/test_resume_streaming.py b/plugins/Runner/dify-agent/tests/test_resume_streaming.py index 142ea8cf5..dfcf9cc3a 100644 --- a/plugins/Runner/dify-agent/tests/test_resume_streaming.py +++ b/plugins/Runner/dify-agent/tests/test_resume_streaming.py @@ -1,7 +1,6 @@ -"""Streaming and deduplication for resumed Dify workflows.""" +"""Streaming and deduplication after a blocking Dify form submission.""" from types import SimpleNamespace -from unittest.mock import AsyncMock import pytest from components.runner.default import DefaultRunner @@ -17,11 +16,7 @@ async def _resume(events): "workflow_run_id": "workflow", "user": "user", } - runner = SimpleNamespace( - _load_interaction_continuation=AsyncMock(return_value=continuation), - _store_interaction_continuation=AsyncMock(), - _delete_interaction_continuation=AsyncMock(), - ) + runner = SimpleNamespace() class Client: async def workflow_submit(self, **kwargs): @@ -32,7 +27,7 @@ async def _resume(events): result = [ item async for item in DefaultRunner._resume_workflow_once( - runner, SimpleNamespace(run_id="run"), Client(), submission, False + runner, SimpleNamespace(run_id="run"), Client(), submission, False, continuation ) ] return [item.data["chunk"] for item in result if item.type == "message.delta"], result diff --git a/plugins/Runner/dify-agent/tests/test_scoped_identity.py b/plugins/Runner/dify-agent/tests/test_scoped_identity.py index ab31018ac..8424620d5 100644 --- a/plugins/Runner/dify-agent/tests/test_scoped_identity.py +++ b/plugins/Runner/dify-agent/tests/test_scoped_identity.py @@ -16,7 +16,6 @@ PLUGIN_ROOT = Path(__file__).resolve().parents[1] if str(PLUGIN_ROOT) not in sys.path: sys.path.insert(0, str(PLUGIN_ROOT)) -from components.runner.default import DefaultRunner, DifyAPIError # noqa: E402 from pkg.scoped_identity import scoped_identity # noqa: E402 @@ -62,7 +61,6 @@ def test_identity_is_stable_for_the_same_binding(): return scoped_identity(_runner(handler), _ctx(), "local-1") assert compute(1) == compute(1) - # Only the installation scope matters: a worker upgrade must not rename upstream state. assert compute(1) == compute(7) @@ -95,97 +93,3 @@ def test_dedicated_falls_back_to_workspace_then_legacy_local_id(monkeypatch): assert scoped.startswith("lb_") assert scoped != scoped_identity(_runner(handler), _ctx(workspace_id="workspace-2"), "local-1") assert scoped_identity(_runner(handler), _ctx(), "local-1") == "local-1" - - -# --- Dify continuation owner ------------------------------------------------- - - -def _dify_context(): - return SimpleNamespace( - conversation=SimpleNamespace( - conversation_id="conversation-1", - launcher_type="group", - launcher_id="launcher-1", - workspace_id=None, - ), - actor=SimpleNamespace(actor_id="actor-1"), - config={"base-url": "https://dify.invalid"}, - ) - - -def _dify_runner(handler): - runner = object.__new__(DefaultRunner) - runner._plugin_runtime_handler = handler - return runner - - -def _legacy_owner(binding: InstallationBinding, subject: str) -> str: - scope = [binding.instance_uuid, binding.workspace_uuid, binding.installation_uuid] - encoded = json.dumps([scope, subject], ensure_ascii=False, separators=(",", ":")).encode() - return "lb_" + hashlib.sha256(encoded).hexdigest() - - -class _StorageAPI: - def __init__(self, payload: bytes): - self._payload = payload - - async def get_plugin_storage(self, key: str) -> bytes: - return self._payload - - -def _continuation_payload(owner: str) -> bytes: - return json.dumps( - { - "interaction_id": "interaction-1", - "version": 1, - "owner": owner, - "expires_at": 4_000_000_000, - } - ).encode("utf-8") - - -@pytest.mark.asyncio -async def test_resume_concurrency_key_uses_the_same_owner_and_binding(): - ctx = _dify_context() - handler = SimpleNamespace() - runner = _dify_runner(handler) - binding = _binding("installation-a") - with bind_invocation(handler, binding=binding): - owner = runner._continuation_owner(ctx) - assert owner == _expected(binding, runner._continuation_subject(ctx)) - # Pre-seeding the exact key proves _resume_workflow derives it from the same - # invocation binding and owner rather than a different scope. - runner._active_resumes = {(binding, owner, "interaction-1")} - stream = runner._resume_workflow( - ctx, - client=None, - submission=SimpleNamespace(interaction_id="interaction-1"), - remove_think=False, - ) - with pytest.raises(DifyAPIError): - await stream.__anext__() - - -@pytest.mark.asyncio -async def test_legacy_owner_is_accepted_and_migrated(): - ctx = _dify_context() - legacy_binding = _binding("installation-legacy") - handler = SimpleNamespace(bound_action_context=legacy_binding) - runner = _dify_runner(handler) - subject = runner._continuation_subject(ctx) - runner.get_run_api = lambda _ctx: _StorageAPI(_continuation_payload(_legacy_owner(legacy_binding, subject))) - binding = _binding("installation-current") - with bind_invocation(handler, binding=binding): - current_owner = runner._continuation_owner(ctx) - assert current_owner != _legacy_owner(legacy_binding, subject) - continuation = await runner._load_interaction_continuation(ctx, "interaction-1") - assert continuation["owner"] == current_owner - - -@pytest.mark.asyncio -async def test_foreign_owner_is_rejected(): - ctx = _dify_context() - runner = _dify_runner(SimpleNamespace()) - runner.get_run_api = lambda _ctx: _StorageAPI(_continuation_payload("lb_" + "0" * 64)) - with pytest.raises(DifyAPIError): - await runner._load_interaction_continuation(ctx, "interaction-1") diff --git a/plugins/Runner/http-certification-tests/test_dify_continuation_parity.py b/plugins/Runner/http-certification-tests/test_dify_continuation_parity.py deleted file mode 100644 index a007c7f96..000000000 --- a/plugins/Runner/http-certification-tests/test_dify_continuation_parity.py +++ /dev/null @@ -1,182 +0,0 @@ -"""Continuation feature parity with owner-bound, expiring new-format records.""" - -import asyncio -import json -import os -import sys -from pathlib import Path -from typing import Any - -from langbot_plugin.api.entities.builtin.runner import InteractionSubmission - -sys.path.insert(0, str(Path(__file__).resolve().parents[1])) -from tests.test_traditional_runner_protocol_fixes import ( - _collect_async, - _ctx, - _FakePluginStorage, - _load_runner_module, - _type, -) - -if os.environ.get("HTTP_RUNNER_SOURCE_ROOT"): - _load_runner_module.__globals__["ROOT"] = Path(os.environ["HTTP_RUNNER_SOURCE_ROOT"]) - - -def test_dify_interaction_resume_submits_mapped_values_and_clears_continuation() -> None: - module = _load_runner_module("dify-agent") - runner = object.__new__(module.DefaultRunner) - storage = _FakePluginStorage() - runner.get_run_api = lambda ctx: storage - interaction_id = "dify-test" - storage_key = module._interaction_storage_key(interaction_id) - storage.values[storage_key] = json.dumps( - { - "version": 1, - "interaction_id": interaction_id, - "form_token": "form-private", - "workflow_run_id": "workflow-private", - "user": "user_1", - "field_map": {"field_1": "priority"}, - "action_map": {"action_1": "approve-private"}, - "default_inputs": {"comment": "default"}, - } - ).encode() - captured: dict[str, Any] = {} - - class FakeClient: - async def workflow_submit(self, **kwargs): - captured.update(kwargs) - yield { - "event": "workflow_finished", - "data": {"error": None, "outputs": {"summary": "Approved"}}, - } - - submission = InteractionSubmission( - interaction_id=interaction_id, - action_id="action_1", - values={"field_1": "high"}, - ) - asyncio.run( - runner._store_interaction_continuation( - _ctx(), json.loads(storage.values[module._interaction_storage_key(interaction_id)]) - ) - ) - results = asyncio.run(_collect_async(runner._resume_workflow(_ctx(), FakeClient(), submission, False))) - - assert captured == { - "form_token": "form-private", - "workflow_run_id": "workflow-private", - "inputs": {"comment": "default", "priority": "high"}, - "user": "user_1", - "action": "approve-private", - } - assert [_type(result) for result in results] == ["message.delta", "run.completed"] - assert storage_key in storage.deleted - assert storage_key not in storage.values - - -def test_dify_field_submission_advances_to_action_without_calling_provider() -> None: - module = _load_runner_module("dify-agent") - runner = object.__new__(module.DefaultRunner) - storage = _FakePluginStorage() - runner.get_run_api = lambda ctx: storage - interaction_id = "dify-field" - storage.values[module._interaction_storage_key(interaction_id)] = json.dumps( - { - "version": 1, - "interaction_id": interaction_id, - "form_token": "form-private", - "workflow_run_id": "workflow-private", - "user": "user_1", - "field_map": {"field_1": "priority"}, - "action_map": {"action_1": "approve-private"}, - "default_inputs": {}, - "title": "Manual review", - "description": "Review it", - "fallback_text": "Review it", - "phase": "field", - "current_fields": [ - { - "id": "field_1", - "label": "Priority", - "type": "select", - "required": True, - "options": [{"value": "high", "label": "High"}], - } - ], - "remaining_fields": [], - "interaction_actions": [{"id": "action_1", "label": "Approve", "style": "primary"}], - } - ).encode() - - class ProviderMustNotRun: - def workflow_submit(self, **kwargs): - raise AssertionError("provider must not resume before the action step") - - submission = InteractionSubmission(interaction_id=interaction_id, values={"field_1": "high"}) - asyncio.run( - runner._store_interaction_continuation( - _ctx(), json.loads(storage.values[module._interaction_storage_key(interaction_id)]) - ) - ) - results = asyncio.run(_collect_async(runner._resume_workflow(_ctx(), ProviderMustNotRun(), submission, False))) - - assert [_type(result) for result in results] == ["action.requested"] - request = results[0].data["payload"] - assert request["fields"] == [] - assert request["actions"][0]["label"] == "Approve" - continuation = json.loads(storage.values[module._interaction_storage_key(request["interaction_id"])]) - assert continuation["default_inputs"] == {"priority": "high"} - - -def test_dify_interaction_resume_can_pause_again() -> None: - module = _load_runner_module("dify-agent") - runner = object.__new__(module.DefaultRunner) - storage = _FakePluginStorage() - runner.get_run_api = lambda ctx: storage - interaction_id = "dify-first" - old_key = module._interaction_storage_key(interaction_id) - storage.values[old_key] = json.dumps( - { - "version": 1, - "interaction_id": interaction_id, - "form_token": "form-1", - "workflow_run_id": "workflow-1", - "user": "user_1", - "field_map": {}, - "action_map": {"continue": ""}, - "default_inputs": {}, - } - ).encode() - - class FakeClient: - async def workflow_submit(self, **kwargs): - yield { - "event": "workflow_paused", - "data": { - "workflow_run_id": "workflow-2", - "reasons": [ - { - "TYPE": "human_input_required", - "form_token": "form-2", - "node_title": "Second review", - "inputs": [], - "actions": [{"id": "done", "title": "Done"}], - } - ], - }, - } - - submission = InteractionSubmission(interaction_id=interaction_id, action_id="continue") - asyncio.run( - runner._store_interaction_continuation( - _ctx(), json.loads(storage.values[module._interaction_storage_key(interaction_id)]) - ) - ) - results = asyncio.run(_collect_async(runner._resume_workflow(_ctx(), FakeClient(), submission, False))) - - assert [_type(result) for result in results] == ["action.requested"] - new_interaction_id = results[0].data["payload"]["interaction_id"] - assert new_interaction_id != interaction_id - assert old_key in storage.deleted - assert module._interaction_storage_key(new_interaction_id) in storage.values diff --git a/plugins/Runner/http-certification-tests/test_http_lifecycle.py b/plugins/Runner/http-certification-tests/test_http_lifecycle.py index f2908b0da..2a9d96922 100644 --- a/plugins/Runner/http-certification-tests/test_http_lifecycle.py +++ b/plugins/Runner/http-certification-tests/test_http_lifecycle.py @@ -116,73 +116,6 @@ def test_total_deadline_includes_trickle_stream(name, monkeypatch): asyncio.run(run()) -def test_dify_continuation_rejects_cross_conversation(): - with load("dify") as (m, runner): - - async def run(): - data = {} - - async def put(k, v): - data[k] = v - - async def get(k): - return data[k] - - runner.get_run_api = lambda ctx: SimpleNamespace(set_plugin_storage=put, get_plugin_storage=get) - a = context() - b = context() - b.conversation.conversation_id = "other" - await runner._store_interaction_continuation(a, {"interaction_id": "i", "version": 1}) - with pytest.raises(m.DifyAPIError): - await runner._load_interaction_continuation(b, "i") - - asyncio.run(run()) - - -def test_dify_failed_resume_is_not_replayed(): - with load("dify") as (m, runner): - - async def run(): - data = {} - calls = [] - - async def put(k, v): - data[k] = v - - async def get(k): - return data[k] - - runner.get_run_api = lambda ctx: SimpleNamespace(set_plugin_storage=put, get_plugin_storage=get) - - class Client: - async def workflow_submit(self, **kwargs): - calls.append(kwargs) - raise ConnectionError("ambiguous remote failure") - yield - - ctx = context() - await runner._store_interaction_continuation( - ctx, - { - "interaction_id": "i", - "version": 1, - "phase": "action", - "action_map": {"yes": "approve"}, - "form_token": "fixture", - "workflow_run_id": "w", - "user": "u", - }, - ) - submission = SimpleNamespace(interaction_id="i", values={}, action_id="yes") - for _ in range(2): - with pytest.raises((ConnectionError, m.DifyAPIError)): - async for _ in runner._resume_workflow(ctx, Client(), submission, False): - pass - assert len(calls) == 1 - - asyncio.run(run()) - - @pytest.mark.parametrize("name", GATEWAYS) def test_same_installation_gateway_overlap_keeps_other_token_alive(name): async def run(runner): diff --git a/plugins/Runner/http-certification-tests/test_isolation.py b/plugins/Runner/http-certification-tests/test_isolation.py index f3b0926ac..e4cd12fda 100644 --- a/plugins/Runner/http-certification-tests/test_isolation.py +++ b/plugins/Runner/http-certification-tests/test_isolation.py @@ -171,16 +171,3 @@ def test_gateway_overlap_has_independent_timeout_and_tokens(name): with load(name) as (_, runner): with gateway_invocation(runner): asyncio.run(run(runner)) - - -def test_dify_storage_transport_failure_not_misreported_absent(): - async def run(module, runner): - async def broken(key): - raise PermissionError("fixture denial") - - runner.get_run_api = lambda ctx: SimpleNamespace(get_plugin_storage=broken) - with pytest.raises(PermissionError): - await runner._load_interaction_continuation(context(), "id") - - with load("dify") as (m, runner): - asyncio.run(run(m, runner)) diff --git a/plugins/Runner/tests/test_plugin_layout.py b/plugins/Runner/tests/test_plugin_layout.py index fa84c1a99..2ee3945ef 100644 --- a/plugins/Runner/tests/test_plugin_layout.py +++ b/plugins/Runner/tests/test_plugin_layout.py @@ -1113,11 +1113,10 @@ def test_claude_code_runner_non_streaming_emits_one_message(tmp_path: Path) -> N assert "message" not in results[2].data -def test_claude_code_runner_pauses_for_question_and_resumes_with_tool_result(tmp_path: Path) -> None: +def test_claude_code_runner_rejects_builtin_question_tool(tmp_path: Path) -> None: fake_cli = tmp_path / "fake_native_cli.py" _write_fake_native_cli(fake_cli) module = _load_runner_module("claude-code-agent") - native = sys.modules["pkg.native_cli"] runner = object.__new__(module.DefaultRunner) runner.get_plugin_config = lambda: {} runner.get_run_api = lambda ctx: None @@ -1129,45 +1128,9 @@ def test_claude_code_runner_pauses_for_question_and_resumes_with_tool_result(tmp "langbot-assets-enabled": False, } - paused = asyncio.run(_collect_async(runner.run(_native_ctx(config, text="ASK_INTERACTION")))) - - assert [item.type for item in paused] == ["state.updated", "state.updated", "action.requested"] - pending = paused[1].data["value"] - request = paused[2].data["payload"] - assert paused[2].data["action"] == "interaction.requested" - assert request["fields"][0]["type"] == "select" - assert request["fields"][0]["label"] == "Choose an environment" - - resumed = asyncio.run( - _collect_async( - runner.run( - _native_ctx( - config, - conversation_state={ - native.SESSION_STATE_KEY: "fake-session", - native.PENDING_INTERACTION_STATE_KEY: pending, - }, - interaction=InteractionSubmission( - interaction_id=request["interaction_id"], - action_id="submit", - values={"environment": "staging"}, - ), - ) - ) - ) - ) - - assert resumed[0].data == { - "key": native.PENDING_INTERACTION_STATE_KEY, - "value": None, - "scope": "conversation", - } - assert resumed[-1].type == "run.completed" - message_text = "".join( - str(item.data.get("chunk", {}).get("content") or "") for item in resumed if item.type == "message.delta" - ) - assert "authoritative response" in message_text - assert '"answer": "staging"' in message_text + results = asyncio.run(_collect_async(runner.run(_native_ctx(config, text="ASK_INTERACTION")))) + assert [item.type for item in results] == ["state.updated", "run.failed"] + assert results[-1].data["code"] == "claude_code.interaction_invalid" def test_codex_runner_executes_fake_native_cli(tmp_path: Path) -> None: @@ -1558,12 +1521,11 @@ def test_codex_app_server_auto_approves_legacy_request_when_policy_is_never() -> "params": {"command": "touch probe.txt", "cwd": "/workspace"}, } ) - return json.loads(stdin.data.decode("utf-8")), client.interaction_request + return json.loads(stdin.data.decode("utf-8")) - response, interaction_request = asyncio.run(approve()) + response = asyncio.run(approve()) assert response == {"jsonrpc": "2.0", "id": 42, "result": {"decision": "accept"}} - assert interaction_request is None def test_codex_local_home_is_unique_per_resumed_run(tmp_path: Path) -> None: diff --git a/plugins/Runner/tests/test_steering.py b/plugins/Runner/tests/test_steering.py index d1c73422f..bfff79fef 100644 --- a/plugins/Runner/tests/test_steering.py +++ b/plugins/Runner/tests/test_steering.py @@ -218,7 +218,7 @@ def test_steering_loop_stops_on_action_request_without_completing() -> None: ) yield RunnerResult.action_requested( "run_steering", - "interaction.requested", + "test.action", {"interaction_id": "question-1"}, ) diff --git a/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py b/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py index 8430e1e1b..0d6730f01 100644 --- a/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py +++ b/plugins/Runner/tests/test_traditional_runner_protocol_fixes.py @@ -104,7 +104,7 @@ def _dify_interaction_ctx(): submission=InteractionSubmission( interaction_id=request.interaction_id, action_id=request.actions[0].id if request.actions else None, - values={request.fields[0].id: "high"} if request.fields else {}, + values={field.id: ("action_1" if field.id == "_dify_action" else "high") for field in request.fields}, ), ) @@ -265,7 +265,8 @@ def test_dify_workflow_pause_requests_host_interaction_and_hides_provider_tokens assert _type(results[0]) == "tool.call.started" assert results[0].data["tool_name"] == "Manual review" assert requests[0].fields[0].options[0].value == "high" - assert requests[1].actions[0].label == "Approve" + assert len(requests) == 1 + assert requests[0].actions == [] assert "form-private" not in str(requests) assert "workflow-private" not in str(requests) assert storage.values == {} @@ -341,39 +342,31 @@ def test_dify_pause_accepts_single_reason_and_camel_case_fields() -> None: }, } - result = asyncio.run(runner._interaction_result_for_pause(_ctx(), event, "user_1")) + request, continuation = runner._build_interaction_request(event["data"]["reason"], "workflow-private", "user_1") - assert result is not None - request = result.data["payload"] - assert request["title"] == "Manual review" - assert request["fields"][0]["default"] == "low" - assert request["fields"][0]["options"][0]["value"] == "high" - assert request["fields"][0]["options"][0]["label"] == "high" - continuation = json.loads(next(iter(storage.values.values()))) + assert request.title == "Manual review" + assert request.fields[0].default == "low" + assert request.fields[0].options[0].value == "high" + assert request.fields[0].options[0].label == "high" assert continuation["action_map"] == {"action_1": "approve-private"} assert "form-private" not in str(request) assert "workflow-private" not in str(request) + assert storage.values == {} -def test_dify_interaction_resume_submits_mapped_values_and_clears_continuation() -> None: +def test_dify_interaction_resume_submits_mapped_values_in_same_run() -> None: module = _load_runner_module("dify-agent") runner = object.__new__(module.DefaultRunner) - storage = _FakePluginStorage() - runner.get_run_api = lambda ctx: storage interaction_id = "dify-test" - storage_key = module._interaction_storage_key(interaction_id) - storage.values[storage_key] = json.dumps( - { - "version": 1, - "interaction_id": interaction_id, - "form_token": "form-private", - "workflow_run_id": "workflow-private", - "user": "user_1", - "field_map": {"field_1": "priority"}, - "action_map": {"action_1": "approve-private"}, - "default_inputs": {"comment": "default"}, - } - ).encode() + continuation = { + "interaction_id": interaction_id, + "form_token": "form-private", + "workflow_run_id": "workflow-private", + "user": "user_1", + "field_map": {"field_1": "priority"}, + "action_map": {"action_1": "approve-private"}, + "default_inputs": {"comment": "default"}, + } captured: dict[str, Any] = {} class FakeClient: @@ -389,13 +382,9 @@ def test_dify_interaction_resume_submits_mapped_values_and_clears_continuation() action_id="action_1", values={"field_1": "high"}, ) - # Persist through the real contract: owner scope, expiry and pending status. - asyncio.run( - runner._store_interaction_continuation( - _ctx(), json.loads(storage.values[module._interaction_storage_key(interaction_id)]) - ) + results = asyncio.run( + _collect_async(runner._resume_workflow_once(_ctx(), FakeClient(), submission, False, continuation)) ) - results = asyncio.run(_collect_async(runner._resume_workflow(_ctx(), FakeClient(), submission, False))) assert captured == { "form_token": "form-private", @@ -405,66 +394,32 @@ def test_dify_interaction_resume_submits_mapped_values_and_clears_continuation() "action": "approve-private", } assert [_type(result) for result in results] == ["message.delta", "run.completed"] - assert storage_key in storage.deleted - assert storage_key not in storage.values -def test_dify_field_submission_advances_to_action_without_calling_provider() -> None: +def test_dify_multi_field_form_is_requested_once() -> None: module = _load_runner_module("dify-agent") runner = object.__new__(module.DefaultRunner) - storage = _FakePluginStorage() - runner.get_run_api = lambda ctx: storage - interaction_id = "dify-field" - storage.values[module._interaction_storage_key(interaction_id)] = json.dumps( + request, continuation = runner._build_interaction_request( { - "version": 1, - "interaction_id": interaction_id, "form_token": "form-private", - "workflow_run_id": "workflow-private", - "user": "user_1", - "field_map": {"field_1": "priority"}, - "action_map": {"action_1": "approve-private"}, - "default_inputs": {}, - "title": "Manual review", - "description": "Review it", - "fallback_text": "Review it", - "phase": "field", - "current_fields": [ - { - "id": "field_1", - "label": "Priority", - "type": "select", - "required": True, - "options": [{"value": "high", "label": "High"}], - } + "node_title": "Manual review", + "inputs": [ + {"output_variable_name": "first", "type": "text"}, + {"output_variable_name": "second", "type": "text"}, ], - "remaining_fields": [], - "interaction_actions": [{"id": "action_1", "label": "Approve", "style": "primary"}], - } - ).encode() - - class ProviderMustNotRun: - def workflow_submit(self, **kwargs): - raise AssertionError("provider must not resume before the action step") - - submission = InteractionSubmission(interaction_id=interaction_id, values={"field_1": "high"}) - # Persist through the real contract: owner scope, expiry and pending status. - asyncio.run( - runner._store_interaction_continuation( - _ctx(), json.loads(storage.values[module._interaction_storage_key(interaction_id)]) - ) + "actions": [{"id": "approve", "title": "Approve"}, {"id": "reject", "title": "Reject"}], + }, + "workflow-private", + "user_1", ) - results = asyncio.run(_collect_async(runner._resume_workflow(_ctx(), ProviderMustNotRun(), submission, False))) - assert [_type(result) for result in results] == ["action.requested"] - request = results[0].data["payload"] - assert request["fields"] == [] - assert request["actions"][0]["label"] == "Approve" - continuation = json.loads(storage.values[module._interaction_storage_key(request["interaction_id"])]) - assert continuation["default_inputs"] == {"priority": "high"} + assert [field.id for field in request.fields] == ["field_1", "field_2", "_dify_action"] + assert [option.value for option in request.fields[-1].options] == ["action_1", "action_2"] + assert request.actions == [] + assert continuation["field_map"] == {"field_1": "first", "field_2": "second"} -def test_dify_form_content_is_revealed_with_its_current_field() -> None: +def test_dify_form_content_is_sent_with_all_fields() -> None: module = _load_runner_module("dify-agent") runner = object.__new__(module.DefaultRunner) storage = _FakePluginStorage() @@ -489,92 +444,56 @@ def test_dify_form_content_is_revealed_with_its_current_field() -> None: "user-1", ) - assert request.description == "1\n请输入你的问题" - assert request.fields[0].label == "us_input" - assert "请选择你的答案" not in request.description - - asyncio.run(runner._store_interaction_continuation(_ctx(), continuation)) - second_result = asyncio.run( - runner._advance_field_interaction( - _ctx(), - continuation, - InteractionSubmission(interaction_id=request.interaction_id, values={"field_1": "问题内容"}), - ) - ) - second_request = second_result.data["payload"] - assert second_request["description"] == "请选择你的答案" - assert second_request["fields"][0]["label"] == "xiala" - assert "请输入你的问题" not in second_request["description"] - - second_continuation = json.loads(storage.values[module._interaction_storage_key(second_request["interaction_id"])]) - final_result = asyncio.run( - runner._advance_field_interaction( - _ctx(), - second_continuation, - InteractionSubmission( - interaction_id=second_request["interaction_id"], - values={"field_2": "1"}, - ), - ) - ) - final_request = final_result.data["payload"] - assert final_request["description"] is None - assert final_request["fields"] == [] - assert final_request["actions"][0]["label"] == "or" + assert request.description == "1\n请输入你的问题\n\n请选择你的答案" + assert [field.label for field in request.fields] == ["us_input", "xiala"] + assert request.actions == [] + assert continuation["action_map"] == {"action_1": "or-private"} def test_dify_interaction_resume_can_pause_again() -> None: module = _load_runner_module("dify-agent") runner = object.__new__(module.DefaultRunner) - storage = _FakePluginStorage() - runner.get_run_api = lambda ctx: storage - interaction_id = "dify-first" - old_key = module._interaction_storage_key(interaction_id) - storage.values[old_key] = json.dumps( - { - "version": 1, - "interaction_id": interaction_id, - "form_token": "form-1", - "workflow_run_id": "workflow-1", - "user": "user_1", - "field_map": {}, - "action_map": {"continue": ""}, - "default_inputs": {}, - } - ).encode() + continuation = { + "interaction_id": "dify-first", + "form_token": "form-1", + "workflow_run_id": "workflow-1", + "user": "user_1", + "field_map": {}, + "action_map": {"continue": ""}, + "default_inputs": {}, + } class FakeClient: async def workflow_submit(self, **kwargs): - yield { - "event": "workflow_paused", - "data": { - "workflow_run_id": "workflow-2", - "reasons": [ - { - "TYPE": "human_input_required", - "form_token": "form-2", - "node_title": "Second review", - "inputs": [], - "actions": [{"id": "done", "title": "Done"}], - } - ], - }, - } + if kwargs["form_token"] == "form-1": + yield { + "event": "workflow_paused", + "data": { + "workflow_run_id": "workflow-2", + "reasons": [ + { + "TYPE": "human_input_required", + "form_token": "form-2", + "node_title": "Second review", + "inputs": [], + "actions": [{"id": "done", "title": "Done"}], + } + ], + }, + } + else: + yield {"event": "node_finished", "data": {"node_type": "answer", "outputs": {"answer": "Done"}}} + yield {"event": "workflow_finished", "data": {"outputs": {}}} - submission = InteractionSubmission(interaction_id=interaction_id, action_id="continue") - # Persist through the real contract: owner scope, expiry and pending status. - asyncio.run( - runner._store_interaction_continuation( - _ctx(), json.loads(storage.values[module._interaction_storage_key(interaction_id)]) - ) + ctx, requests = _dify_interaction_ctx() + submission = InteractionSubmission(interaction_id="dify-first", action_id="continue") + results = asyncio.run( + _collect_async(runner._resume_workflow_once(ctx, FakeClient(), submission, False, continuation)) ) - results = asyncio.run(_collect_async(runner._resume_workflow(_ctx(), FakeClient(), submission, False))) - assert [_type(result) for result in results] == ["action.requested"] - new_interaction_id = results[0].data["payload"]["interaction_id"] - assert new_interaction_id != interaction_id - assert old_key in storage.deleted - assert module._interaction_storage_key(new_interaction_id) in storage.values + assert len(requests) == 1 + assert requests[0].title == "Second review" + assert _type(results[-1]) == "run.completed" def test_dify_client_submits_form_then_streams_resumed_events(monkeypatch) -> None: diff --git a/src/langbot/pkg/agent/runner/context_builder.py b/src/langbot/pkg/agent/runner/context_builder.py index 0f9d73729..47133a366 100644 --- a/src/langbot/pkg/agent/runner/context_builder.py +++ b/src/langbot/pkg/agent/runner/context_builder.py @@ -249,19 +249,7 @@ class RunnerContextBuilder: 'timestamp': event.event_time or int(time.time()), } - # A legacy Tbox resume must use the persisted original bot, not a - # newly reconstructed callback envelope. Ordinary sender mode is unchanged. bot_id = event.bot_id - if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') == 'legacy-bot': - identity = event.legacy_identity - bot_id = ( - identity.bot_id - if identity is not None - and identity.workspace_id == event.workspace_id - and identity.bot_id == event.bot_id - and identity.pipeline_id == (binding.processor_id or binding.agent_id) - else None - ) # Build conversation context from event conversation: ConversationContext | None = None diff --git a/src/langbot/pkg/agent/runner/events.py b/src/langbot/pkg/agent/runner/events.py index 4fa988244..4f422149c 100644 --- a/src/langbot/pkg/agent/runner/events.py +++ b/src/langbot/pkg/agent/runner/events.py @@ -15,16 +15,11 @@ GROUP_MEMBER_JOINED = 'group.member_joined' FRIEND_REQUEST_RECEIVED = 'friend.request_received' """A new friend/contact request was received.""" -INTERACTION_SUBMITTED = 'interaction.submitted' -"""A user submitted a Host-rendered structured interaction.""" - - RESERVED_EVENT_TYPES = frozenset( { MESSAGE_RECEIVED, MESSAGE_RECALLED, GROUP_MEMBER_JOINED, FRIEND_REQUEST_RECEIVED, - INTERACTION_SUBMITTED, } ) diff --git a/src/langbot/pkg/agent/runner/interaction_manager.py b/src/langbot/pkg/agent/runner/interaction_manager.py index 2fa371770..0aeedac82 100644 --- a/src/langbot/pkg/agent/runner/interaction_manager.py +++ b/src/langbot/pkg/agent/runner/interaction_manager.py @@ -18,15 +18,10 @@ from langbot_plugin.api.entities.builtin.runner.interaction import ( InteractionSubmission, ) -from .descriptor import RunnerDescriptor from .errors import RunnerProtocolError -from .host_models import AgentBinding, AgentEventEnvelope, LegacyRunnerIdentity from .interaction_store import InteractionStore -INTERACTION_REQUESTED_ACTION = 'interaction.requested' -INTERACTION_DELIVERY_API = 'interaction.request' -INTERACTION_ACKNOWLEDGE_API = 'interaction.acknowledge' INTERACTION_PAYLOAD_MAX_BYTES = 256 * 1024 DEFAULT_INTERACTION_TTL_SECONDS = 30 * 60 MAX_INTERACTION_TTL_SECONDS = 24 * 60 * 60 @@ -188,7 +183,7 @@ class InteractionManager: supported_apis = adapter.get_supported_apis() if callable(getattr(adapter, 'get_supported_apis', None)) else [] if supported_apis and 'send_interaction' not in supported_apis: capabilities = InteractionDeliveryCapabilities(text_fallback=capabilities.text_fallback) - if supported_apis and 'interaction.acknowledge' not in supported_apis: + if supported_apis and 'update_interaction' not in supported_apis: capabilities.supports_updates = False normalized, degraded, use_text, issues = self._normalize_request(request, capabilities, on_unsupported) if issues: @@ -381,6 +376,9 @@ class InteractionManager: future.set_result(record['submission']) return True + def waiting_run_ids(self) -> set[str]: + return {run_id for (run_id, _), future in self._waiters.items() if not future.done()} + async def consume_text_message( self, *, @@ -438,269 +436,6 @@ class InteractionManager: return self.wake_waiter(record, actor_id) return False - async def handle_result( - self, - *, - result_dict: dict[str, typing.Any], - event: AgentEventEnvelope, - binding: AgentBinding, - descriptor: RunnerDescriptor, - run_id: str, - adapter_context: dict[str, typing.Any] | None, - ) -> bool: - """Execute an authorized interaction request; return whether it was consumed.""" - data = result_dict.get('data') - if not isinstance(data, dict) or data.get('action') != INTERACTION_REQUESTED_ACTION: - return False - - self._authorize(descriptor, binding) - payload = data.get('payload') - try: - request = HostInteractionRequest.model_validate(payload) - except pydantic.ValidationError as exc: - raise RunnerProtocolError(descriptor.id, f'invalid interaction request: {exc}') from exc - - processor_id = binding.processor_id or binding.agent_id - if not processor_id: - raise RunnerProtocolError(descriptor.id, 'interaction request has no stable processor identity') - adapter = self._resolve_adapter(adapter_context) - if adapter is None: - raise RunnerProtocolError(descriptor.id, 'interaction request has no delivery adapter') - - request_data = request.model_dump(mode='json') - now = int(time.time()) - expires_at = now + DEFAULT_INTERACTION_TTL_SECONDS if request.expires_at is None else request.expires_at - if expires_at <= now: - raise RunnerProtocolError(descriptor.id, 'interaction request is already expired') - if expires_at > now + MAX_INTERACTION_TTL_SECONDS: - raise RunnerProtocolError(descriptor.id, 'interaction request expiry exceeds 24 hours') - request_data['expires_at'] = expires_at - if len(json.dumps(request_data, ensure_ascii=False).encode('utf-8')) > INTERACTION_PAYLOAD_MAX_BYTES: - raise RunnerProtocolError(descriptor.id, 'interaction request exceeds 256 KiB') - self._validate_request_identity(request, descriptor.id) - reply_target = event.delivery.reply_target or {} - target_type = reply_target.get('target_type') - target_id = reply_target.get('target_id') - callback_conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id - update_target = await self._find_update_target( - event=event, - binding=binding, - descriptor=descriptor, - processor_id=processor_id, - conversation_id=callback_conversation_id, - ) - _, callback_token = await self.store.create_request( - interaction_id=request.interaction_id, - run_id=run_id, - binding_id=binding.binding_id, - runner_id=descriptor.id, - processor_type=binding.processor_type, - processor_id=processor_id, - request={ - **request_data, - '_host_legacy_identity': event.legacy_identity.model_dump(mode='json') - if event.legacy_identity is not None - else None, - }, - delivery_target=reply_target, - replaces_interaction_id=(update_target or {}).get('interaction_id'), - bot_id=event.bot_id, - workspace_id=event.workspace_id, - conversation_id=callback_conversation_id, - thread_id=event.thread_id, - actor_id=event.actor.actor_id if event.actor else None, - expires_at=expires_at, - **self._pipeline_admission(binding, descriptor, adapter_context), - ) - - try: - if self._supports_interaction_delivery(adapter): - delivery_params = { - 'request': request_data, - 'callback_token': callback_token, - 'reply_target': event.delivery.reply_target, - } - if update_target is not None: - delivery_params['update_target'] = update_target.get('delivery_result') - try: - delivery_result = await adapter.call_platform_api( - INTERACTION_DELIVERY_API, - delivery_params, - ) - except Exception as exc: - if update_target is None: - raise - self._warning(f'Interaction update failed; sending a new presentation: {exc}') - fallback_params = dict(delivery_params) - fallback_params.pop('update_target', None) - delivery_result = await adapter.call_platform_api( - INTERACTION_DELIVERY_API, - fallback_params, - ) - if isinstance(delivery_result, dict): - try: - await self.store.record_delivery_success( - run_id, - request.interaction_id, - delivery_result, - ) - except Exception as exc: - self._warning(f'Failed to persist interaction delivery result: {exc}') - else: - await self._deliver_fallback(adapter, event, request.fallback_text) - except Exception as exc: - await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc)) - raise - return True - - async def present_tool_request( - self, - *, - request_payload: dict[str, typing.Any], - session: typing.Mapping[str, typing.Any], - adapter: typing.Any, - ) -> dict[str, typing.Any]: - """Persist and deliver an Agent-authored semantic card using Host-owned authority.""" - authorization = session.get('authorization') if isinstance(session, typing.Mapping) else None - if not isinstance(authorization, dict): - raise ValueError('Interactive card run authorization is unavailable') - processor_type = authorization.get('processor_type') - if processor_type not in {'agent', 'pipeline'}: - raise ValueError('Interactive card tools are unavailable for this processor type') - if authorization.get('interactions_enabled') is not True: - raise ValueError('Interactive cards are disabled for this processor binding') - - run_id = str(session.get('run_id') or '').strip() - runner_id = str(session.get('runner_id') or '').strip() - binding_id = str(authorization.get('binding_id') or '').strip() - processor_id = str(authorization.get('processor_id') or '').strip() - if not all((run_id, runner_id, binding_id, processor_id)): - raise ValueError('Interactive card run identity is incomplete') - try: - request = HostInteractionRequest.model_validate(request_payload) - except pydantic.ValidationError as exc: - raise ValueError(f'Invalid interactive card: {exc}') from exc - self._validate_request_identity(request, runner_id) - - request_data = request.model_dump(mode='json') - now = int(time.time()) - expires_at = request.expires_at or now + DEFAULT_INTERACTION_TTL_SECONDS - if expires_at <= now or expires_at > now + MAX_INTERACTION_TTL_SECONDS: - raise ValueError('Interactive card expiry must be in the future and within 24 hours') - request_data['expires_at'] = expires_at - if len(json.dumps(request_data, ensure_ascii=False).encode('utf-8')) > INTERACTION_PAYLOAD_MAX_BYTES: - raise ValueError('Interactive card exceeds 256 KiB') - - platform_context = authorization.get('platform_context') - if not isinstance(platform_context, dict): - raise ValueError('Interactive card platform context is unavailable') - delivery = platform_context.get('delivery') if isinstance(platform_context.get('delivery'), dict) else {} - self._validate_tool_delivery_capabilities(request, delivery) - reply_target = delivery.get('reply_target') if isinstance(delivery.get('reply_target'), dict) else {} - target_type = reply_target.get('target_type') - target_id = reply_target.get('target_id') - if not target_type or not target_id: - raise ValueError('Interactive card has no authorized reply target') - callback_conversation_id = f'{target_type}_{target_id}' - actor = platform_context.get('actor') if isinstance(platform_context.get('actor'), dict) else {} - pipeline_admission = self._pipeline_tool_admission( - session=session, - authorization=authorization, - runner_id=runner_id, - ) - - _, callback_token = await self.store.create_request( - interaction_id=request.interaction_id, - run_id=run_id, - binding_id=binding_id, - runner_id=runner_id, - processor_type=processor_type, - processor_id=processor_id, - request=request_data, - delivery_target=reply_target, - bot_id=authorization.get('bot_id'), - workspace_id=authorization.get('workspace_id'), - conversation_id=callback_conversation_id, - thread_id=authorization.get('thread_id'), - actor_id=actor.get('actor_id'), - expires_at=expires_at, - **pipeline_admission, - ) - try: - if not self._supports_interaction_delivery(adapter): - raise ValueError('The current platform does not support interactive cards') - delivery_result = await adapter.call_platform_api( - INTERACTION_DELIVERY_API, - { - 'request': request_data, - 'callback_token': callback_token, - 'reply_target': reply_target, - }, - ) - if isinstance(delivery_result, dict): - await self.store.record_delivery_success(run_id, request.interaction_id, delivery_result) - except Exception as exc: - await self.store.mark_delivery_failed(run_id, request.interaction_id, str(exc)) - raise - return { - 'ok': True, - 'status': 'pending', - 'interaction_id': request.interaction_id, - 'expires_at': expires_at, - } - - @staticmethod - def _validate_tool_delivery_capabilities( - request: HostInteractionRequest, - delivery: dict[str, typing.Any], - ) -> None: - capabilities = delivery.get('interactions') - if not isinstance(capabilities, dict): - raise ValueError('The current platform did not declare interactive card capabilities') - if request.fields and request.actions: - raise ValueError('An interactive card step cannot combine fields and actions') - field_types = set(capabilities.get('field_types') or []) - unsupported_types = {field.type for field in request.fields} - field_types - if unsupported_types: - raise ValueError( - 'The current platform does not support interaction fields: ' + ', '.join(sorted(unsupported_types)) - ) - max_fields = capabilities.get('max_fields') - if isinstance(max_fields, int) and len(request.fields) > max_fields: - raise ValueError(f'The current platform supports at most {max_fields} interaction fields') - action_styles = set(capabilities.get('action_styles') or []) - unsupported_styles = {action.style for action in request.actions} - action_styles - if unsupported_styles: - raise ValueError( - 'The current platform does not support interaction action styles: ' - + ', '.join(sorted(unsupported_styles)) - ) - - @staticmethod - def _pipeline_admission(binding, descriptor, adapter_context): - if binding.processor_type != 'pipeline': - return {} - query = (adapter_context or {}).get('_query') - expected = copy.deepcopy((adapter_context or {}).get('_pipeline_expected_config')) - session = getattr(query, 'session', None) - conversation = (adapter_context or {}).get('_pipeline_conversation') - - def authority(): - from .config_resolver import RunnerConfigResolver - - return ( - query is not None - and session is not None - and conversation is not None - and isinstance(expected, dict) - and session.using_conversation is conversation - and getattr(query, 'pipeline_uuid', None) == binding.processor_id - and RunnerConfigResolver.resolve_runner_id(expected) == descriptor.id == binding.runner_id - and RunnerConfigResolver.resolve_runner_config(expected, descriptor.id) == binding.runner_config - ) - - return {'expected_config': expected, 'authority_check': authority} - @staticmethod def _pipeline_tool_admission(session, authorization, runner_id): if authorization.get('processor_type') != 'pipeline': @@ -729,83 +464,6 @@ class InteractionManager: return {'expected_config': expected, 'authority_check': authority} - async def restore_legacy_identity(self, event: AgentEventEnvelope, binding: AgentBinding) -> None: - """Resume only from the scoped, consumed Host record, never callback fields.""" - event.legacy_identity = None - submission = getattr(event.input, 'interaction', None) - if submission is None: - return - if hasattr(submission, 'model_dump'): - submission = submission.model_dump(mode='json') - record = await self.store.find_resume_request(event=event, binding=binding, submission=submission) - if record is None: - return - identity = record['request'].get('_host_legacy_identity') - if not isinstance(identity, dict): - return - try: - event.legacy_identity = LegacyRunnerIdentity.model_validate(identity) - except pydantic.ValidationError: - return - - async def acknowledge_submission( - self, - record: dict[str, typing.Any], - adapter: typing.Any, - callback_context: dict[str, typing.Any] | None = None, - ) -> None: - """Best-effort transition of submitted controls into a read-only state.""" - delivery_result = record.get('delivery_result') - if not isinstance(delivery_result, dict) or not self._supports_platform_api( - adapter, INTERACTION_ACKNOWLEDGE_API - ): - return - try: - delivery_result = await adapter.call_platform_api( - INTERACTION_ACKNOWLEDGE_API, - { - 'request': record.get('request') or {}, - 'submission': record.get('submission') or {}, - 'update_target': delivery_result, - **({'callback_context': callback_context} if callback_context else {}), - }, - ) - if isinstance(delivery_result, dict): - await self.store.record_delivery_success( - record['run_id'], - record['interaction_id'], - delivery_result, - ) - except Exception as exc: - self._warning(f'Failed to acknowledge interaction submission: {exc}') - - async def _find_update_target( - self, - *, - event: AgentEventEnvelope, - binding: AgentBinding, - descriptor: RunnerDescriptor, - processor_id: str, - conversation_id: str | None, - ) -> dict[str, typing.Any] | None: - capabilities = getattr(event.delivery, 'interactions', None) - if not capabilities or not getattr(capabilities, 'supports_updates', False): - return None - prior = getattr(event.input, 'interaction', None) - prior_interaction_id = getattr(prior, 'interaction_id', None) - if not prior_interaction_id: - return None - return await self.store.find_update_target( - interaction_id=str(prior_interaction_id), - binding_id=binding.binding_id, - runner_id=descriptor.id, - processor_type=binding.processor_type, - processor_id=processor_id, - bot_id=event.bot_id, - conversation_id=conversation_id, - actor_id=event.actor.actor_id if event.actor else None, - ) - @staticmethod def _validate_request_identity(request: HostInteractionRequest, runner_id: str) -> None: field_ids = [field.id for field in request.fields] @@ -946,53 +604,8 @@ class InteractionManager: if any(selected not in allowed_values for selected in selected_values): raise ValueError(f'interaction submission option is not present in the request: {field_id}') - @staticmethod - def _authorize(descriptor: RunnerDescriptor, binding: AgentBinding) -> None: - supports_interactions = bool(getattr(descriptor.capabilities, 'interactions', False)) - permissions = set(getattr(descriptor.permissions, 'interactions', []) or []) - if not supports_interactions or 'request' not in permissions: - raise RunnerProtocolError(descriptor.id, 'runner did not declare interaction capability and permission') - if not binding.delivery_policy.enable_interactions: - raise RunnerProtocolError(descriptor.id, 'binding delivery policy does not allow interactions') - - @staticmethod - def _resolve_adapter(adapter_context: dict[str, typing.Any] | None) -> typing.Any | None: - if not adapter_context: - return None - query = adapter_context.get('_query') - if query is not None and getattr(query, 'adapter', None) is not None: - return query.adapter - return adapter_context.get('_delivery_adapter') - - @staticmethod - def _supports_interaction_delivery(adapter: typing.Any) -> bool: - return InteractionManager._supports_platform_api(adapter, INTERACTION_DELIVERY_API) - - @staticmethod - def _supports_platform_api(adapter: typing.Any, api_name: str) -> bool: - get_supported_apis = getattr(adapter, 'get_supported_apis', None) - if not callable(get_supported_apis): - return False - try: - return api_name in (get_supported_apis() or []) - except Exception: - return False - def _warning(self, message: str) -> None: logger = getattr(self.ap, 'logger', None) warning = getattr(logger, 'warning', None) if callable(warning): warning(message) - - @staticmethod - async def _deliver_fallback(adapter: typing.Any, event: AgentEventEnvelope, text: str) -> None: - target = event.delivery.reply_target or {} - target_type = target.get('target_type') - target_id = target.get('target_id') - if not target_type or not target_id: - raise ValueError('interaction fallback has no reply target') - await adapter.send_message( - str(target_type), - str(target_id), - platform_message.MessageChain([platform_message.Plain(text=text)]), - ) diff --git a/src/langbot/pkg/agent/runner/interaction_store.py b/src/langbot/pkg/agent/runner/interaction_store.py index 4faa42c23..f1df825ed 100644 --- a/src/langbot/pkg/agent/runner/interaction_store.py +++ b/src/langbot/pkg/agent/runner/interaction_store.py @@ -223,79 +223,6 @@ class InteractionStore: await session.commit() return result.rowcount == 1 - async def find_update_target( - self, - *, - interaction_id: str, - binding_id: str, - runner_id: str, - processor_type: str, - processor_id: str, - bot_id: str | None, - conversation_id: str | None, - actor_id: str | None, - ) -> dict[str, typing.Any] | None: - """Find a submitted interaction whose platform presentation can be replaced.""" - if not interaction_id: - return None - conditions = [ - AgentInteraction.interaction_id == interaction_id, - AgentInteraction.binding_id == binding_id, - AgentInteraction.runner_id == runner_id, - AgentInteraction.processor_type == processor_type, - AgentInteraction.processor_id == processor_id, - AgentInteraction.status == 'submitted', - AgentInteraction.delivery_result_json.is_not(None), - ] - for column, value in ( - (AgentInteraction.bot_id, bot_id), - (AgentInteraction.conversation_id, conversation_id), - (AgentInteraction.actor_id, actor_id), - ): - conditions.append(column.is_(None) if value is None else column == value) - - async with self._session_factory() as session: - result = await session.execute( - sqlalchemy.select(AgentInteraction) - .where(*conditions) - .order_by(AgentInteraction.submitted_at.desc(), AgentInteraction.id.desc()) - .limit(1) - ) - row = result.scalar_one_or_none() - return self._to_dict(row) if row is not None else None - - async def find_resume_request(self, *, event, binding, submission) -> dict[str, typing.Any] | None: - """Resolve persisted provenance with exact tenant/processor/callback scope. - - Ambiguous identities fail closed instead of picking the newest run. - """ - if not isinstance(submission, dict) or not submission.get('interaction_id'): - return None - target = event.delivery.reply_target or {} - target_type, target_id = target.get('target_type'), target.get('target_id') - conversation_id = f'{target_type}_{target_id}' if target_type and target_id else event.conversation_id - conditions = [ - AgentInteraction.interaction_id == submission['interaction_id'], - AgentInteraction.status == 'submitted', - AgentInteraction.binding_id == binding.binding_id, - AgentInteraction.runner_id == binding.runner_id, - AgentInteraction.processor_type == binding.processor_type, - AgentInteraction.processor_id == (binding.processor_id or binding.agent_id), - ] - for column, value in ( - (AgentInteraction.workspace_id, event.workspace_id), - (AgentInteraction.bot_id, event.bot_id), - (AgentInteraction.conversation_id, conversation_id), - (AgentInteraction.thread_id, event.thread_id), - (AgentInteraction.actor_id, event.actor.actor_id if event.actor else None), - ): - conditions.append(column.is_(None) if value is None else column == value) - async with self._session_factory() as session: - result = await session.execute(sqlalchemy.select(AgentInteraction).where(*conditions)) - records = [self._to_dict(row) for row in result.scalars()] - matches = [record for record in records if record['submission'] == submission] - return matches[0] if len(matches) == 1 else None - async def get_request(self, run_id: str, interaction_id: str) -> dict[str, typing.Any] | None: """Return a request by the runner-visible identity.""" async with self._session_factory() as session: @@ -380,103 +307,6 @@ class InteractionStore: raise InteractionNotFoundError('Interaction disappeared after submission') return self._to_dict(refreshed) - async def consume_text_reply( - self, - *, - workspace_id: str, - bot_id: str, - conversation_id: str, - actor_id: str, - binding_id: str, - runner_id: str, - processor_id: str, - text: str, - message_id: str, - message_time: int | float | None = None, - ) -> dict[str, typing.Any] | None: - """Consume the latest scoped, plain-text form waiting for this user.""" - answer = text.strip() - if ( - not answer - or len(answer) > 20000 - or not all( - (workspace_id, bot_id, conversation_id, actor_id, binding_id, runner_id, processor_id, message_id) - ) - ): - return None - now = _utc_now() - source_marker = 'text-reply:' + _token_hash(message_id) - scope = ( - AgentInteraction.workspace_id == workspace_id, - AgentInteraction.bot_id == bot_id, - AgentInteraction.conversation_id == conversation_id, - AgentInteraction.actor_id == actor_id, - AgentInteraction.binding_id == binding_id, - AgentInteraction.runner_id == runner_id, - AgentInteraction.processor_type == 'agent', - AgentInteraction.processor_id == processor_id, - ) - async with self._session_factory() as session: - duplicate = await session.execute( - sqlalchemy.select(AgentInteraction.id) - .where( - *scope, - AgentInteraction.status == 'submitted', - AgentInteraction.status_reason == source_marker, - ) - .limit(1) - ) - if duplicate.scalar_one_or_none() is not None: - return {'duplicate_text_reply': True} - result = await session.execute( - sqlalchemy.select(AgentInteraction) - .where(*scope) - .order_by(AgentInteraction.created_at.desc(), AgentInteraction.id.desc()) - .limit(1) - ) - row = result.scalar_one_or_none() - if row is None or row.status != 'pending' or (row.expires_at and _as_utc(row.expires_at) <= now): - return None - if message_time is not None and float(message_time) < _as_utc(row.created_at).timestamp() - 1: - return None - delivery = _json_loads(row.delivery_result_json, None) - request = _json_loads(row.request_json, {}) - fields = request.get('fields') if isinstance(request, dict) else None - if not (isinstance(delivery, dict) and delivery.get('ok') is True and delivery.get('rich') is False): - return None - if not ( - isinstance(fields, list) - and len(fields) == 1 - and isinstance(fields[0], dict) - and fields[0].get('type') in {'text', 'textarea'} - and fields[0].get('id') - and not request.get('actions') - ): - return None - submission = { - 'interaction_id': row.interaction_id, - 'action_id': None, - 'values': {fields[0]['id']: answer}, - 'submitted_at': int(now.timestamp()), - } - updated = await session.execute( - sqlalchemy.update(AgentInteraction) - .where(AgentInteraction.id == row.id, AgentInteraction.status == 'pending') - .values( - status='submitted', - status_reason=source_marker, - submitted_at=_db_datetime(now), - submission_json=_json_dumps(submission), - updated_at=_db_datetime(now), - ) - ) - if updated.rowcount != 1: - await session.rollback() - return None - await session.commit() - refreshed = await session.get(AgentInteraction, row.id) - return self._to_dict(refreshed) - async def mark_delivery_failed(self, run_id: str, interaction_id: str, reason: str) -> bool: """Mark an undelivered pending interaction terminal.""" now = _utc_now() diff --git a/src/langbot/pkg/agent/runner/orchestrator.py b/src/langbot/pkg/agent/runner/orchestrator.py index d47a3a481..a047e6569 100644 --- a/src/langbot/pkg/agent/runner/orchestrator.py +++ b/src/langbot/pkg/agent/runner/orchestrator.py @@ -139,12 +139,6 @@ class AgentRunOrchestrator: project_mcp_resource_config(execution_query, binding.runner_config) object.__setattr__(execution_query, '_execution_context', execution_context) - if event.event_type == 'interaction.submitted' and binding.runner_config.get('user-id-source') in ( - 'legacy-session', - 'legacy-bot', - ): - await self.interaction_manager.restore_legacy_identity(event, binding) - from ...box.runner import prepare_input_files event = event.model_copy(deep=True) @@ -266,7 +260,7 @@ class AgentRunOrchestrator: auto_reply_enabled = ( binding.processor_type == 'agent' and binding.delivery_policy.enable_reply - and event.event_type in {'message.received', 'interaction.submitted'} + and event.event_type == 'message.received' and reply_streams.adapter is not None ) auto_reply_stream_id = uuid4() @@ -457,17 +451,6 @@ class AgentRunOrchestrator: await self.result_normalizer.normalize(result_dict, descriptor) continue - if result_type == 'action.requested': - if await self.interaction_manager.handle_result( - result_dict=result_dict, - event=event, - binding=binding, - descriptor=descriptor, - run_id=run_id, - adapter_context=adapter_context, - ): - continue - if result_type == 'run.completed': terminal_status = 'completed' terminal_reason = ( @@ -774,6 +757,59 @@ class AgentRunOrchestrator: self.ap.logger.info(f'Claimed event {event.event_id} as steering input for run {target_run_id}') return True + async def try_claim_steering_from_event( + self, + event: AgentEventEnvelope, + binding: AgentBinding, + execution_context: ExecutionContext, + ) -> bool: + """Route a new platform message to an Agent waiting for interaction.""" + if event.event_type != 'message.received' or not event.conversation_id: + return False + waiting_run_ids = self.interaction_manager.waiting_run_ids() + if not waiting_run_ids: + return False + descriptor = await self.registry.get(execution_context, binding.runner_id) + if not descriptor.supports_steering(): + return False + target_run_id = await self._session_registry.find_steering_target( + conversation_id=event.conversation_id, + runner_id=descriptor.id, + bot_id=event.bot_id, + workspace_id=event.workspace_id, + thread_id=event.thread_id, + binding_id=binding.binding_id, + eligible_run_ids=waiting_run_ids, + ) + if target_run_id is None: + return False + steering_item = self._build_steering_item(event, target_run_id, descriptor.id) + if not await self._session_registry.enqueue_steering(target_run_id, steering_item): + return False + try: + event_log_id = await self.journal.write_event_log( + event=event, + binding=binding, + run_id=target_run_id, + runner_id=descriptor.id, + metadata={ + 'steering': { + 'status': 'queued', + 'trigger_behavior': 'absorbed_into_active_run', + 'claimed_by_run_id': target_run_id, + 'claimed_runner_id': descriptor.id, + 'claimed_at': steering_item.get('claimed_at'), + } + }, + ) + await self.journal.write_user_transcript(event, event_log_id) + except Exception as exc: + self.ap.logger.warning( + f'Failed to persist steering event {event.event_id} for run {target_run_id}: {exc}', + exc_info=True, + ) + return True + def _build_steering_item( self, event: AgentEventEnvelope, diff --git a/src/langbot/pkg/agent/runner/platform_tools.py b/src/langbot/pkg/agent/runner/platform_tools.py index a5582f858..ef93ab84d 100644 --- a/src/langbot/pkg/agent/runner/platform_tools.py +++ b/src/langbot/pkg/agent/runner/platform_tools.py @@ -90,7 +90,7 @@ _INTERACTION_ACTION_SCHEMA = { PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = ( PlatformToolDefinition( PRESENT_INTERACTIVE_CARD_TOOL, - 'interaction.request', + 'send_interaction', 'event', 'interaction', 'write', @@ -115,7 +115,7 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = ( }, ['title'], ), - ('message.*', 'interaction.submitted'), + ('message.*',), 'reply_target', ), PlatformToolDefinition( @@ -132,7 +132,7 @@ PLATFORM_TOOL_DEFINITIONS: tuple[PlatformToolDefinition, ...] = ( 'Call this tool to send an explicit message. Direct Agent text output may also be delivered automatically.', }, _object_schema({'text': {**_TEXT, 'description': 'Reply text'}}, ['text']), - ('message.*', 'friend.*', 'group.*', 'feedback.*', 'interaction.submitted'), + ('message.*', 'friend.*', 'group.*', 'feedback.*'), 'reply_target', ), PlatformToolDefinition( @@ -872,7 +872,11 @@ async def execute_platform_tool( and {'reply_message', 'get_message'} <= supported_apis and (delivery.get('reply_target') or {}).get('message_id') ) - if definition.api not in supported_apis and not reply_via_source: + if ( + definition.api not in supported_apis + and not reply_via_source + and not (definition.name == PRESENT_INTERACTIVE_CARD_TOOL and 'send_message' in supported_apis) + ): raise ValueError(f'Platform API {definition.api} is no longer supported by bot {bot_id}') if definition.name == PRESENT_INTERACTIVE_CARD_TOOL: manager = getattr(getattr(ap, 'agent_run_orchestrator', None), 'interaction_manager', None) diff --git a/src/langbot/pkg/agent/runner/query_entry_adapter.py b/src/langbot/pkg/agent/runner/query_entry_adapter.py index cdc4051ab..3ad830179 100644 --- a/src/langbot/pkg/agent/runner/query_entry_adapter.py +++ b/src/langbot/pkg/agent/runner/query_entry_adapter.py @@ -132,14 +132,10 @@ class QueryEntryAdapter: pipeline_config = query.pipeline_config or {} runner_config = RunnerConfigResolver.resolve_runner_config(pipeline_config, runner_id) agent_id = getattr(query, 'pipeline_uuid', None) - variables = getattr(query, 'variables', None) or {} - event_type = ( - runner_events.INTERACTION_SUBMITTED - if isinstance(variables.get('_interaction_submission'), dict) - else runner_events.MESSAGE_RECEIVED - ) platform_tool_names = [ - name for name in resolve_agent_platform_tool_names({}, event_type) if name == PRESENT_INTERACTIVE_CARD_TOOL + name + for name in resolve_agent_platform_tool_names({}, runner_events.MESSAGE_RECEIVED) + if name == PRESENT_INTERACTIVE_CARD_TOOL ] resource_policy = ResourcePolicyProjector.from_runner_config( @@ -173,7 +169,7 @@ class QueryEntryAdapter: resource_policy=resource_policy, state_policy=state_policy, delivery_policy=delivery_policy, - event_types=[event_type], + event_types=[runner_events.MESSAGE_RECEIVED], metadata={'source': 'pipeline_adapter'}, ) @@ -253,17 +249,10 @@ class QueryEntryAdapter: event_time = cls._normalize_event_time(getattr(message_event, 'time', None) if message_event else None) - variables = getattr(query, 'variables', None) or {} - interaction = variables.get('_interaction_submission') - event_type = runner_events.MESSAGE_RECEIVED - if isinstance(interaction, dict): - event_type = runner_events.INTERACTION_SUBMITTED - event_data['interaction'] = interaction - source_event_id = str(message_id or query.query_id) return AgentEventContext( event_id=cls._build_scoped_event_id(query, source_event_id, event_time), - event_type=event_type, + event_type=runner_events.MESSAGE_RECEIVED, event_time=event_time, source='host_adapter', source_event_type=source_event_type, @@ -421,15 +410,6 @@ class QueryEntryAdapter: if launcher_type is not None: launcher_type_value = getattr(launcher_type, 'value', launcher_type) - variables = getattr(query, 'variables', None) or {} - interaction = variables.get('_interaction_submission') - if isinstance(interaction, dict): - return SubjectContext( - subject_type='interaction', - subject_id=str(interaction.get('interaction_id') or ''), - data={'action_id': interaction.get('action_id')}, - ) - return SubjectContext( subject_type='message', subject_id=str(message_id or query_id or ''), @@ -500,10 +480,6 @@ class QueryEntryAdapter: 'contents': contents, 'attachments': attachments, } - variables = getattr(query, 'variables', None) or {} - interaction = variables.get('_interaction_submission') - if isinstance(interaction, dict) and 'interaction' in AgentInput.model_fields: - input_data['interaction'] = interaction return AgentInput.model_validate(input_data) @classmethod @@ -664,7 +640,7 @@ class QueryEntryAdapter: 'supported_apis': supported_apis, }, } - if 'interactions' in DeliveryContext.model_fields and 'interaction.request' in supported_apis: + if 'interactions' in DeliveryContext.model_fields and 'send_interaction' in supported_apis: get_capabilities = getattr(adapter, 'get_interaction_capabilities', None) if callable(get_capabilities): capabilities = get_capabilities() diff --git a/src/langbot/pkg/agent/runner/reply_stream.py b/src/langbot/pkg/agent/runner/reply_stream.py index 609c5f9c8..8e06963b6 100644 --- a/src/langbot/pkg/agent/runner/reply_stream.py +++ b/src/langbot/pkg/agent/runner/reply_stream.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import logging -import time from dataclasses import dataclass, field from typing import Literal from uuid import UUID, uuid4 @@ -31,8 +30,6 @@ class _Reply: text: str = '' started: bool = False native: bool = False - interaction_origin: str = '' - last_sent_at: float = 0.0 final_attempted: bool = False sequence: int = 0 status: str = 'open' @@ -48,11 +45,6 @@ class ReplyStreamSession: if not isinstance(self.source, events.MessageEvent): self.source = None self.target = dict(event.delivery.reply_target or {}) - self.interaction_origin = ( - str(self.target.get('message_id') or '') - if getattr(event, 'event_type', None) == 'interaction.submitted' - else '' - ) self.mock = ( event.delivery.surface == 'webui' and (event.delivery.platform_capabilities or {}).get('debug_mock') is True ) @@ -103,24 +95,12 @@ class ReplyStreamSession: and callable(getattr(self.adapter, 'create_message_card_for_target', None)) and callable(getattr(self.adapter, 'reply_message_chunk_for_target', None)) ) - interaction_streaming = ( - self.source is None - and bool(self.interaction_origin) - and callable(getattr(self.adapter, 'begin_interaction_stream', None)) - and callable(getattr(self.adapter, 'reply_interaction_stream_chunk', None)) - ) reply.native = ( - self.source is not None or target_streaming or interaction_streaming + self.source is not None or target_streaming ) and await self.adapter.is_stream_output_supported() if reply.native: if self.source is not None: await self.adapter.create_message_card(reply.delivery_id, self.source) - elif interaction_streaming: - reply.native = await self.adapter.begin_interaction_stream( - self.interaction_origin, self.target - ) - if reply.native: - reply.interaction_origin = self.interaction_origin else: target_type, target_id = self.target.get('target_type'), self.target.get('target_id') if not target_type or not target_id: @@ -129,23 +109,7 @@ class ReplyStreamSession: reply.delivery_id, str(target_type), str(target_id) ) if reply.started and reply.native: - should_refresh = ( - request.operation != 'update' - or not reply.interaction_origin - or reply.sequence == 0 - or time.monotonic() - reply.last_sent_at >= 0.5 - ) - if should_refresh: - try: - await self._update_native(key, reply, final=request.operation != 'update') - reply.last_sent_at = time.monotonic() - except Exception: - if not reply.interaction_origin: - raise - logger.warning( - 'WeCom interaction stream failed; sending the final reply proactively', exc_info=True - ) - reply.native = False + await self._update_native(key, reply, final=request.operation != 'update') if ( reply.started and not reply.native @@ -200,12 +164,6 @@ class ReplyStreamSession: quote_origin=False, is_final=final, ) - elif reply.interaction_origin: - await self.adapter.reply_interaction_stream_chunk( - source_message_id=reply.interaction_origin, - content=reply.text, - is_final=final, - ) else: await self.adapter.reply_message_chunk_for_target( message_id=reply.delivery_id, content=reply.text, is_final=final diff --git a/src/langbot/pkg/agent/runner/session_registry.py b/src/langbot/pkg/agent/runner/session_registry.py index 40c8edb96..753bb44f2 100644 --- a/src/langbot/pkg/agent/runner/session_registry.py +++ b/src/langbot/pkg/agent/runner/session_registry.py @@ -288,12 +288,18 @@ class AgentRunSessionRegistry: bot_id: str | None = None, workspace_id: str | None = None, thread_id: str | None = None, + binding_id: str | None = None, + eligible_run_ids: set[str] | None = None, ) -> str | None: """Find the oldest active run that can accept steering for a conversation.""" async with self._lock: candidates: list[tuple[int, str]] = [] for run_id, session in self._sessions.items(): authorization = session['authorization'] + if eligible_run_ids is not None and run_id not in eligible_run_ids: + continue + if binding_id is not None and authorization.get('binding_id') != binding_id: + continue if session.get('runner_id') != runner_id: continue if authorization.get('conversation_id') != conversation_id: diff --git a/src/langbot/pkg/pipeline/resprule/resprule.py b/src/langbot/pkg/pipeline/resprule/resprule.py index 3e8c16a96..621f51e60 100644 --- a/src/langbot/pkg/pipeline/resprule/resprule.py +++ b/src/langbot/pkg/pipeline/resprule/resprule.py @@ -37,13 +37,7 @@ class GroupRespondRuleCheckStage(stage.PipelineStage): if query.launcher_type.value != 'group': # 只处理群消息 return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) - # Interaction submissions and routed platform form callbacks resume the - # original run without another mention or prefix. Ordinary routing only - # selects a pipeline and must still obey its group response rules. - if query.variables and ( - query.variables.get('_interaction_submission') - or (query.variables.get('_routed_by_rule') and query.variables.get('_dify_form_action')) - ): + if query.variables and query.variables.get('_routed_by_rule') and query.variables.get('_dify_form_action'): return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) rules = query.pipeline_config['trigger']['group-respond-rules'] diff --git a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py index 2a39fbee4..9e5b0c5ed 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/adapter.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/adapter.py @@ -15,7 +15,6 @@ from langbot.libs.dingtalk_api.dingtalkevent import DingTalkEvent import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger from langbot.pkg.platform.adapters.dingtalk.api_impl import DingTalkAPIMixin -from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin from langbot.pkg.platform.adapters.dingtalk.event_converter import DingTalkEventConverter from langbot.pkg.platform.adapters.dingtalk.message_converter import DingTalkMessageConverter from langbot.pkg.platform.adapters.dingtalk.platform_api import PLATFORM_API_MAP @@ -29,6 +28,11 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler): @@ -93,7 +97,7 @@ class DingTalkCardCallbackHandler(dingtalk_stream.CallbackHandler): return dingtalk_stream.AckMessage.STATUS_OK, 'OK' -class DingTalkAdapter(DingTalkAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter): +class DingTalkAdapter(DingTalkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): bot: DingTalkClient = pydantic.Field(exclude=True) message_converter: DingTalkMessageConverter = DingTalkMessageConverter() @@ -150,6 +154,7 @@ class DingTalkAdapter(DingTalkAPIMixin, InteractionAdapterMixin, abstract_platfo 'group.info_updated', 'dingtalk.card_action', 'platform.specific', + 'interaction.callback', ] def get_supported_apis(self) -> list[str]: @@ -166,14 +171,53 @@ class DingTalkAdapter(DingTalkAPIMixin, InteractionAdapterMixin, abstract_platfo 'call_platform_api', ] if hasattr(self.bot, 'create_and_deliver_ai_card'): - apis.extend(['interaction.request', 'send_interaction', 'get_interaction_capabilities']) + apis.extend(['send_interaction', 'get_interaction_capabilities']) if hasattr(self.bot, 'update_ai_card'): - apis.extend(['interaction.acknowledge', 'update_interaction']) + apis.append('update_interaction') return apis def get_interaction_capabilities(self) -> dict[str, typing.Any]: return interaction_delivery_capabilities() + async def send_interaction( + self, + target_type: str, + target_id: str, + request: InteractionRequest, + callback_token: str, + reply_to: str | None = None, + ) -> InteractionHandle: + result = await send_interaction( + self, + { + 'request': request.model_dump(mode='json'), + 'callback_token': callback_token, + 'reply_target': {'target_type': target_type, 'target_id': target_id, 'message_id': reply_to}, + }, + ) + handle = InteractionHandle.model_validate(result) + handle.context['request'] = request.model_dump(mode='json') + return handle + + async def update_interaction( + self, + handle: InteractionHandle, + state: str, + submission: InteractionSubmission | None = None, + ) -> InteractionHandle: + result = await acknowledge_interaction( + self, + { + 'update_target': handle.model_dump(mode='json'), + 'request': handle.context['request'], + 'submission': submission.model_dump(mode='json') if submission else {}, + 'state': state, + }, + ) + updated = InteractionHandle.model_validate(result) + updated.context.update(handle.context) + return updated + @staticmethod def _plain_message(text: str) -> platform_message.MessageChain: return platform_message.MessageChain([platform_message.Plain(text=text)]) @@ -290,10 +334,6 @@ class DingTalkAdapter(DingTalkAPIMixin, InteractionAdapterMixin, abstract_platfo return is_stream_card_configured(self.config) async def call_platform_api(self, action: str, params: dict = {}) -> dict: - if action == 'interaction.request' and action in self.get_supported_apis(): - return await send_interaction(self, params) - if action == 'interaction.acknowledge' and action in self.get_supported_apis(): - return await acknowledge_interaction(self, params) handler = PLATFORM_API_MAP.get(action) if handler is None: raise NotSupportedError(f'call_platform_api:{action}') diff --git a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py index a46835ec8..f1845bee6 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/interaction.py +++ b/src/langbot/pkg/platform/adapters/dingtalk/interaction.py @@ -248,7 +248,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - reply_target = params.get('reply_target') callback_token = str(params.get('callback_token') or '') if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token: - raise ValueError('interaction.request requires request, reply_target, and callback_token') + raise ValueError('send_interaction requires request, reply_target, and callback_token') target_type = str(reply_target.get('target_type') or '') target_id = str(reply_target.get('target_id') or '') fields = request.get('fields') if isinstance(request.get('fields'), list) else [] @@ -266,7 +266,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - result = await adapter.send_message(target_type, target_id, adapter._plain_message(fallback)) return {'ok': True, 'message_id': result.message_id, 'rich': False} if target_type not in {'group', 'person'} or not target_id: - raise ValueError('interaction.request has an invalid DingTalk target') + raise ValueError('send_interaction has an invalid DingTalk target') update_target = params.get('update_target') submitted_values = _stored_submitted_values( update_target.get('submitted_values') if isinstance(update_target, dict) else None @@ -321,15 +321,18 @@ async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing. submission = params.get('submission') target = params.get('update_target') if not all(isinstance(value, dict) for value in (request, submission, target)): - raise ValueError('interaction.acknowledge requires request, submission, and update_target') + raise ValueError('update_interaction requires request, submission, and update_target') if target.get('rich') is not True or not target.get('message_id'): raise ValueError('DingTalk interaction acknowledgement requires an A2UI card') out_track_id = str(target['message_id']) - submitted_values = [ - *_stored_submitted_values(target.get('submitted_values')), - *_submission_display_values(request, submission), - ][-50:] + state = params.get('state', 'submitted') + submitted_values = _stored_submitted_values(target.get('submitted_values')) + if state == 'submitted': + submitted_values.extend(_submission_display_values(request, submission)) + else: + submitted_values.append({'label': '', 'value': 'Expired' if state == 'expired' else 'Cancelled'}) + submitted_values = submitted_values[-50:] components, children = _submitted_components(submitted_values) title = str(request.get('title') or '').strip() if title: @@ -510,17 +513,15 @@ def interaction_event_from_native( target_id = space_id[normalized_space_id.index('im_robot.') + len('im_robot.') :] else: raise ValueError('DingTalk interaction callback has no delivery space') - return platform_events.PlatformSpecificEvent( - type='platform.specific', + return platform_events.InteractionCallbackEvent( adapter_name='dingtalk-omni', - action='interaction.submitted', - data={ - **parsed, - 'actor_id': str(callback.get('user_id') or ''), - 'target_type': target_type, - 'target_id': target_id, - 'display_text': 'submitted', - }, + callback_token=str(parsed.get('callback_token') or ''), + values=parsed.get('values') if isinstance(parsed.get('values'), dict) else {}, + actor_id=str(callback.get('user_id') or ''), + target_type=target_type, + target_id=target_id, + callback_context={'outTrackId': _callback_track_id(callback)}, + raw_submission=parsed, timestamp=time.time(), source_platform_object=event, ) diff --git a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml index dedf3567f..8eb47a65e 100644 --- a/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/dingtalk/manifest.yaml @@ -71,6 +71,7 @@ spec: - group.info_updated - dingtalk.card_action - platform.specific + - interaction.callback supported_apis: required: @@ -85,10 +86,8 @@ spec: - get_friend_list - get_file_url - call_platform_api - - interaction.request - send_interaction - get_interaction_capabilities - - interaction.acknowledge - update_interaction platform_specific_apis: diff --git a/src/langbot/pkg/platform/adapters/discord/adapter.py b/src/langbot/pkg/platform/adapters/discord/adapter.py index e5858225f..206275bbb 100644 --- a/src/langbot/pkg/platform/adapters/discord/adapter.py +++ b/src/langbot/pkg/platform/adapters/discord/adapter.py @@ -11,7 +11,6 @@ import pydantic import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger from langbot.pkg.platform.adapters.discord.api_impl import DiscordAPIMixin -from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin from langbot.pkg.platform.adapters.discord.event_converter import DiscordEventConverter from langbot.pkg.platform.adapters.discord.message_converter import DiscordMessageConverter from langbot.pkg.platform.adapters.discord.platform_api import PLATFORM_API_MAP @@ -23,9 +22,14 @@ from langbot.pkg.platform.adapters.discord.interaction import ( ) from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) -class DiscordAdapter(DiscordAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter): +class DiscordAdapter(DiscordAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): bot: discord.Client = pydantic.Field(exclude=True) message_converter: DiscordMessageConverter = DiscordMessageConverter() @@ -186,6 +190,7 @@ class DiscordAdapter(DiscordAPIMixin, InteractionAdapterMixin, abstract_platform 'bot.invited_to_group', 'bot.removed_from_group', 'platform.specific', + 'interaction.callback', ] def get_supported_apis(self) -> list[str]: @@ -205,14 +210,48 @@ class DiscordAdapter(DiscordAPIMixin, InteractionAdapterMixin, abstract_platform 'kick_member', 'leave_group', 'call_platform_api', - 'interaction.request', 'send_interaction', + 'update_interaction', 'get_interaction_capabilities', ] def get_interaction_capabilities(self) -> dict[str, typing.Any]: return interaction_delivery_capabilities() + async def send_interaction( + self, + target_type: str, + target_id: str, + request: InteractionRequest, + callback_token: str, + reply_to: str | None = None, + ) -> InteractionHandle: + result = await send_interaction( + self, + { + 'request': request.model_dump(mode='json'), + 'callback_token': callback_token, + 'reply_target': {'target_type': target_type, 'target_id': target_id, 'message_id': reply_to}, + }, + ) + handle = InteractionHandle.model_validate(result) + handle.context['request'] = request.model_dump(mode='json') + handle.context['target_id'] = target_id + return handle + + async def update_interaction( + self, + handle: InteractionHandle, + state: str, + submission: InteractionSubmission | None = None, + ) -> InteractionHandle: + del submission + channel = await self._get_channel(str(handle.context['target_id'])) + message = await channel.fetch_message(int(handle.message_id)) + label = {'submitted': 'Submitted', 'expired': 'Expired', 'cancelled': 'Cancelled'}[state] + await message.edit(content=f'{handle.context["request"]["title"]}\n\n{label}', view=None) + return handle + async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): content, files = await self.message_converter.yiri2target(message) guild = self.bot.get_guild(int(target_id)) if target_type == 'group' else None @@ -281,8 +320,6 @@ class DiscordAdapter(DiscordAPIMixin, InteractionAdapterMixin, abstract_platform self.listeners.pop(event_type, None) async def call_platform_api(self, action: str, params: dict = {}) -> dict: - if action == 'interaction.request': - return await send_interaction(self, params) handler = PLATFORM_API_MAP.get(action) if handler is None: from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError diff --git a/src/langbot/pkg/platform/adapters/discord/interaction.py b/src/langbot/pkg/platform/adapters/discord/interaction.py index a66e29808..a02a594d2 100644 --- a/src/langbot/pkg/platform/adapters/discord/interaction.py +++ b/src/langbot/pkg/platform/adapters/discord/interaction.py @@ -100,10 +100,10 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - reply_target = params.get('reply_target') callback_token = str(params.get('callback_token') or '') if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token: - raise ValueError('interaction.request requires request, reply_target, and callback_token') + raise ValueError('send_interaction requires request, reply_target, and callback_token') target_id = str(reply_target.get('target_id') or '') if not target_id: - raise ValueError('interaction.request has no target_id') + raise ValueError('send_interaction has no target_id') channel = await adapter._get_channel(target_id) view = build_interaction_view(request, callback_token) sent = await channel.send(content=_content(request, rich=view is not None), view=view) @@ -113,20 +113,16 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - def interaction_event_from_component( interaction: discord.Interaction, parsed: dict[str, typing.Any], -) -> platform_events.PlatformSpecificEvent: +) -> platform_events.InteractionCallbackEvent: if interaction.user is None or interaction.channel is None: raise ValueError('Discord interaction has no actor or channel') - return platform_events.PlatformSpecificEvent( - type='platform.specific', + return platform_events.InteractionCallbackEvent( adapter_name='discord-omni', - action='interaction.submitted', - data={ - **parsed, - 'actor_id': str(interaction.user.id), - 'target_type': 'group' if interaction.guild is not None else 'person', - 'target_id': str(interaction.channel.id), - 'display_text': 'submitted', - }, + callback_token=str(parsed.get('callback_token') or ''), + actor_id=str(interaction.user.id), + target_type='group' if interaction.guild is not None else 'person', + target_id=str(interaction.channel.id), + raw_submission=parsed, timestamp=time.time(), source_platform_object=interaction, ) diff --git a/src/langbot/pkg/platform/adapters/discord/manifest.yaml b/src/langbot/pkg/platform/adapters/discord/manifest.yaml index 354a42961..d696512a0 100644 --- a/src/langbot/pkg/platform/adapters/discord/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/discord/manifest.yaml @@ -45,6 +45,7 @@ spec: - bot.invited_to_group - bot.removed_from_group - platform.specific + - interaction.callback supported_apis: required: @@ -64,8 +65,8 @@ spec: - kick_member - leave_group - call_platform_api - - interaction.request - send_interaction + - update_interaction - get_interaction_capabilities platform_specific_apis: diff --git a/src/langbot/pkg/platform/adapters/interaction_api.py b/src/langbot/pkg/platform/adapters/interaction_api.py deleted file mode 100644 index 735f04200..000000000 --- a/src/langbot/pkg/platform/adapters/interaction_api.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Standard interaction adapter methods backed by existing platform renderers.""" - -from __future__ import annotations - -from langbot_plugin.api.entities.builtin.runner.interaction import ( - InteractionHandle, - InteractionRequest, - InteractionSubmission, -) - - -class InteractionAdapterMixin: - async def send_interaction( - self, - target_type: str, - target_id: str, - request: InteractionRequest, - callback_token: str, - reply_to: str | None = None, - ) -> InteractionHandle: - result = await self.call_platform_api( - 'interaction.request', - { - 'request': request.model_dump(mode='json'), - 'callback_token': callback_token, - 'reply_target': { - 'target_type': target_type, - 'target_id': target_id, - 'message_id': reply_to, - }, - }, - ) - handle = InteractionHandle.model_validate(result) - handle.context['request'] = request.model_dump(mode='json') - return handle - - async def update_interaction( - self, - handle: InteractionHandle, - state: str, - submission: InteractionSubmission | None = None, - ) -> InteractionHandle: - if 'interaction.acknowledge' not in self.get_supported_apis(): - return handle - result = await self.call_platform_api( - 'interaction.acknowledge', - { - 'update_target': handle.model_dump(mode='json'), - 'request': handle.context.get('request') or {}, - 'callback_context': handle.context.get('callback_context') or {}, - 'submission': submission.model_dump(mode='json') if submission else {}, - 'state': state, - }, - ) - return InteractionHandle.model_validate(result) if isinstance(result, dict) else handle diff --git a/src/langbot/pkg/platform/adapters/lark/adapter.py b/src/langbot/pkg/platform/adapters/lark/adapter.py index f5e0e4c3f..3b274cf84 100644 --- a/src/langbot/pkg/platform/adapters/lark/adapter.py +++ b/src/langbot/pkg/platform/adapters/lark/adapter.py @@ -1,7 +1,6 @@ from __future__ import annotations from langbot.pkg.i18n.adapters import adapter_t, localized_adapter -from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin from langbot.pkg.platform.sources.lark import ( LarkAdapter as LegacyLarkAdapter, @@ -81,6 +80,11 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) class AESCipher: @@ -114,7 +118,7 @@ async def _cancel_ws_cache_task(client: typing.Any) -> None: await asyncio.gather(cache_task, return_exceptions=True) -class LarkAdapter(LarkAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter): +class LarkAdapter(LarkAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): bot: lark_oapi.ws.Client = pydantic.Field(exclude=True) api_client: lark_oapi.Client = pydantic.Field(exclude=True) quart_app: quart.Quart = pydantic.Field(exclude=True) @@ -232,6 +236,7 @@ class LarkAdapter(LarkAPIMixin, InteractionAdapterMixin, abstract_platform_adapt 'message.deleted', 'message.reaction', 'platform.specific', + 'interaction.callback', ] def get_supported_apis(self) -> list[str]: @@ -244,8 +249,6 @@ class LarkAdapter(LarkAPIMixin, InteractionAdapterMixin, abstract_platform_adapt 'get_user_info', 'get_file_url', 'call_platform_api', - 'interaction.request', - 'interaction.acknowledge', 'send_interaction', 'update_interaction', 'get_interaction_capabilities', @@ -254,6 +257,45 @@ class LarkAdapter(LarkAPIMixin, InteractionAdapterMixin, abstract_platform_adapt def get_interaction_capabilities(self) -> dict[str, typing.Any]: return interaction_delivery_capabilities() + async def send_interaction( + self, + target_type: str, + target_id: str, + request: InteractionRequest, + callback_token: str, + reply_to: str | None = None, + ) -> InteractionHandle: + result = await send_interaction( + self, + { + 'request': request.model_dump(mode='json'), + 'callback_token': callback_token, + 'reply_target': {'target_type': target_type, 'target_id': target_id, 'message_id': reply_to}, + }, + ) + handle = InteractionHandle.model_validate(result) + handle.context['request'] = request.model_dump(mode='json') + return handle + + async def update_interaction( + self, + handle: InteractionHandle, + state: str, + submission: InteractionSubmission | None = None, + ) -> InteractionHandle: + result = await acknowledge_interaction( + self, + { + 'update_target': handle.model_dump(mode='json'), + 'request': handle.context['request'], + 'submission': submission.model_dump(mode='json') if submission else {}, + 'state': state, + }, + ) + updated = InteractionHandle.model_validate(result) + updated.context.update(handle.context) + return updated + @staticmethod def _plain_message(text: str) -> platform_message.MessageChain: return platform_message.MessageChain([platform_message.Plain(text=text)]) @@ -649,10 +691,6 @@ class LarkAdapter(LarkAPIMixin, InteractionAdapterMixin, abstract_platform_adapt @localized_adapter async def call_platform_api(self, action: str, params: dict = {}) -> dict: - if action == 'interaction.request': - return await send_interaction(self, params) - if action == 'interaction.acknowledge': - return await acknowledge_interaction(self, params) handler = PLATFORM_API_MAP.get(action) if handler is None: raise NotSupportedError(f'call_platform_api:{action}') @@ -1000,11 +1038,11 @@ class LarkAdapter(LarkAPIMixin, InteractionAdapterMixin, abstract_platform_adapt {'toast': {'type': 'success', 'content': adapter_t(self, 'Thank you for your feedback')}} ) - def _interaction_action_response(self, event: platform_events.PlatformSpecificEvent) -> dict[str, typing.Any]: + def _interaction_action_response(self, event: platform_events.InteractionCallbackEvent) -> dict[str, typing.Any]: response: dict[str, typing.Any] = { 'toast': {'type': 'success', 'content': adapter_t(self, 'Submitted')}, } - if not event.data.get('cardkit'): + if not event.raw_submission.get('cardkit'): response['card'] = { 'type': 'raw', 'data': { diff --git a/src/langbot/pkg/platform/adapters/lark/interaction.py b/src/langbot/pkg/platform/adapters/lark/interaction.py index 8896f6316..c202e9942 100644 --- a/src/langbot/pkg/platform/adapters/lark/interaction.py +++ b/src/langbot/pkg/platform/adapters/lark/interaction.py @@ -361,7 +361,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - reply_target = params.get('reply_target') callback_token = str(params.get('callback_token') or '') if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token: - raise ValueError('interaction.request requires request, reply_target, and callback_token') + raise ValueError('send_interaction requires request, reply_target, and callback_token') update_target = params.get('update_target') submitted_values = _stored_submitted_values( @@ -383,7 +383,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - target_type = str(reply_target.get('target_type') or '') target_id = str(reply_target.get('target_id') or '') if target_type not in {'group', 'person'} or not target_id: - raise ValueError('interaction.request has an invalid reply target') + raise ValueError('send_interaction has an invalid reply target') card_request = ( CreateCardRequest.builder() .request_body( @@ -429,15 +429,22 @@ async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing. submission = params.get('submission') update_target = params.get('update_target') if not isinstance(request, dict) or not isinstance(submission, dict) or not isinstance(update_target, dict): - raise ValueError('interaction.acknowledge requires request, submission, and update_target') - submitted_values = [ - *_stored_submitted_values(update_target.get('submitted_values')), - *_submission_display_values(request, submission), - ] + raise ValueError('update_interaction requires request, submission, and update_target') + state = params.get('state', 'submitted') + submitted_values = _stored_submitted_values(update_target.get('submitted_values')) + if state == 'submitted': + submitted_values.extend(_submission_display_values(request, submission)) + else: + submitted_values.append({'label': '', 'value': 'Expired' if state == 'expired' else 'Cancelled'}) + card = ( + build_submitted_card(request, submission, _stored_submitted_values(update_target.get('submitted_values'))) + if state == 'submitted' + else build_submitted_card(request, {}, submitted_values) + ) return await _update_interaction_card( adapter, update_target, - build_submitted_card(request, submission, _stored_submitted_values(update_target.get('submitted_values'))), + card, submitted_values, ) @@ -507,7 +514,7 @@ def _event_from_parts( actor_id: typing.Any, chat_id: typing.Any, message_id: typing.Any, -) -> platform_events.PlatformSpecificEvent | None: +) -> platform_events.InteractionCallbackEvent | None: payload = _mapping(_action_attr(action, 'value')) callback_token = str(payload.get('lbi') or '') if not callback_token: @@ -532,17 +539,21 @@ def _event_from_parts( data['option_ref'] = payload.get('o') if payload.get('fm'): data['values'] = _form_submission_values(action, payload) - return platform_events.PlatformSpecificEvent( - type='platform.specific', + return platform_events.InteractionCallbackEvent( adapter_name='lark-omni', - action='interaction.submitted', - data=data, + callback_token=callback_token, + values=data.get('values', {}), + actor_id=data['actor_id'], + target_type=target_type, + target_id=target_id, + callback_context={'message_id': str(message_id)} if message_id else {}, + raw_submission=data, timestamp=time.time(), source_platform_object=raw, ) -def interaction_event_from_callback(event: typing.Any) -> platform_events.PlatformSpecificEvent | None: +def interaction_event_from_callback(event: typing.Any) -> platform_events.InteractionCallbackEvent | None: action = getattr(getattr(event, 'event', None), 'action', None) operator = getattr(getattr(event, 'event', None), 'operator', None) context = getattr(getattr(event, 'event', None), 'context', None) @@ -555,7 +566,7 @@ def interaction_event_from_callback(event: typing.Any) -> platform_events.Platfo ) -def interaction_event_from_webhook(data: dict[str, typing.Any]) -> platform_events.PlatformSpecificEvent | None: +def interaction_event_from_webhook(data: dict[str, typing.Any]) -> platform_events.InteractionCallbackEvent | None: event = data.get('event') if isinstance(data.get('event'), dict) else {} action = event.get('action') if isinstance(event.get('action'), dict) else {} operator = event.get('operator') if isinstance(event.get('operator'), dict) else {} diff --git a/src/langbot/pkg/platform/adapters/lark/manifest.yaml b/src/langbot/pkg/platform/adapters/lark/manifest.yaml index 42239935c..575d4776c 100644 --- a/src/langbot/pkg/platform/adapters/lark/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/lark/manifest.yaml @@ -252,6 +252,7 @@ spec: - message.deleted - message.reaction - platform.specific + - interaction.callback supported_apis: required: @@ -264,8 +265,6 @@ spec: - get_user_info - get_file_url - call_platform_api - - interaction.request - - interaction.acknowledge - send_interaction - update_interaction - get_interaction_capabilities diff --git a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py index 44d142393..08969592d 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/adapter.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/adapter.py @@ -10,7 +10,6 @@ import pydantic from langbot.libs.qq_official_api.api import QQOfficialClient from langbot.libs.qq_official_api.qqofficialevent import QQOfficialEvent from langbot.pkg.platform.adapters.qqofficial.api_impl import QQOfficialAPIMixin -from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin from langbot.pkg.platform.adapters.qqofficial.errors import NotSupportedError from langbot.pkg.platform.adapters.qqofficial.event_converter import ( BOT_INVITED_EVENT_TYPES, @@ -34,9 +33,14 @@ import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_ from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) -class QQOfficialAdapter(QQOfficialAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter): +class QQOfficialAdapter(QQOfficialAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): bot: typing.Any = pydantic.Field(exclude=True) message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() @@ -113,6 +117,7 @@ class QQOfficialAdapter(QQOfficialAPIMixin, InteractionAdapterMixin, abstract_pl 'bot.invited_to_group', 'bot.removed_from_group', 'platform.specific', + 'interaction.callback', ] def get_supported_apis(self) -> list[str]: @@ -126,14 +131,51 @@ class QQOfficialAdapter(QQOfficialAPIMixin, InteractionAdapterMixin, abstract_pl 'get_group_member_list', 'get_group_member_info', 'call_platform_api', - 'interaction.request', 'send_interaction', + 'update_interaction', 'get_interaction_capabilities', ] def get_interaction_capabilities(self) -> dict[str, typing.Any]: return interaction_delivery_capabilities() + async def send_interaction( + self, + target_type: str, + target_id: str, + request: InteractionRequest, + callback_token: str, + reply_to: str | None = None, + ) -> InteractionHandle: + result = await send_interaction( + self, + { + 'request': request.model_dump(mode='json'), + 'callback_token': callback_token, + 'reply_target': {'target_type': target_type, 'target_id': target_id, 'message_id': reply_to}, + }, + ) + handle = InteractionHandle.model_validate(result) + handle.context['request'] = request.model_dump(mode='json') + handle.context['target_type'] = target_type + handle.context['target_id'] = target_id + return handle + + async def update_interaction( + self, + handle: InteractionHandle, + state: str, + submission: InteractionSubmission | None = None, + ) -> InteractionHandle: + del submission + label = {'submitted': 'Submitted', 'expired': 'Expired', 'cancelled': 'Cancelled'}[state] + await self.send_message( + str(handle.context['target_type']), + str(handle.context['target_id']), + self._plain_message(f'{handle.context["request"]["title"]}: {label}'), + ) + return handle + def next_interaction_msg_seq(self, anchor: str) -> int | None: """Allocate QQ's per-anchor passive reply sequence for interaction cards.""" if not anchor: @@ -190,8 +232,6 @@ class QQOfficialAdapter(QQOfficialAPIMixin, InteractionAdapterMixin, abstract_pl return platform_events.MessageResult(message_id=source.d_id or source.id, raw={'results': raw}) async def call_platform_api(self, action: str, params: dict = {}) -> dict: - if action == 'interaction.request': - return await send_interaction(self, params) handler = PLATFORM_API_MAP.get(action) if handler is None: raise NotSupportedError(f'call_platform_api:{action}') diff --git a/src/langbot/pkg/platform/adapters/qqofficial/interaction.py b/src/langbot/pkg/platform/adapters/qqofficial/interaction.py index 2223cfe64..52d17c490 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/interaction.py +++ b/src/langbot/pkg/platform/adapters/qqofficial/interaction.py @@ -12,7 +12,7 @@ def interaction_delivery_capabilities() -> dict[str, typing.Any]: return { 'field_types': ['select'], 'action_styles': ['default', 'primary', 'danger'], - 'supports_updates': True, + 'supports_updates': False, 'max_fields': 1, } @@ -94,7 +94,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - reply_target = params.get('reply_target') callback_token = str(params.get('callback_token') or '') if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token: - raise ValueError('interaction.request requires request, reply_target, and callback_token') + raise ValueError('send_interaction requires request, reply_target, and callback_token') target_type = str(reply_target.get('target_type') or '') target_id = str(reply_target.get('target_id') or '') keyboard = build_interaction_keyboard(request, callback_token) @@ -149,7 +149,7 @@ def parse_callback_data(value: str) -> dict[str, typing.Any]: def interaction_event_from_payload( event_data: dict[str, typing.Any], -) -> platform_events.PlatformSpecificEvent | None: +) -> platform_events.InteractionCallbackEvent | None: resolved = (event_data.get('data') or {}).get('resolved') or {} callback_data = str(resolved.get('button_data') or '') if not callback_data.startswith('lbi:'): @@ -173,17 +173,13 @@ def interaction_event_from_payload( or ((event_data.get('member') or {}).get('user') or {}).get('id') or '' ) - return platform_events.PlatformSpecificEvent( - type='platform.specific', + return platform_events.InteractionCallbackEvent( adapter_name='qqofficial-omni', - action='interaction.submitted', - data={ - **parsed, - 'actor_id': actor_id, - 'target_type': target_type, - 'target_id': target_id, - 'display_text': 'submitted', - }, + callback_token=str(parsed.get('callback_token') or ''), + actor_id=actor_id, + target_type=target_type, + target_id=target_id, + raw_submission=parsed, timestamp=time.time(), source_platform_object=event_data, ) diff --git a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml index bec35d4f4..d4a7b20e2 100644 --- a/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/qqofficial/manifest.yaml @@ -132,6 +132,7 @@ spec: - bot.invited_to_group - bot.removed_from_group - platform.specific + - interaction.callback supported_apis: required: @@ -145,8 +146,8 @@ spec: - get_group_member_list - get_group_member_info - call_platform_api - - interaction.request - send_interaction + - update_interaction - get_interaction_capabilities platform_specific_apis: diff --git a/src/langbot/pkg/platform/adapters/telegram/adapter.py b/src/langbot/pkg/platform/adapters/telegram/adapter.py index 497f691e9..f05611a87 100644 --- a/src/langbot/pkg/platform/adapters/telegram/adapter.py +++ b/src/langbot/pkg/platform/adapters/telegram/adapter.py @@ -30,7 +30,6 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger from langbot.pkg.platform.adapters.telegram.message_converter import TelegramMessageConverter -from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin from langbot.pkg.platform.adapters.telegram.event_converter import TelegramEventConverter, LegacyEventConverter from langbot.pkg.platform.adapters.telegram.api_impl import TelegramAPIMixin from langbot.pkg.platform.adapters.telegram.platform_api import PLATFORM_API_MAP @@ -40,9 +39,14 @@ from langbot.pkg.platform.adapters.telegram.interaction import ( parse_interaction_callback, send_interaction, ) +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) -class TelegramAdapter(TelegramAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter): +class TelegramAdapter(TelegramAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): """Telegram adapter (EBA version).""" bot: telegram.Bot = pydantic.Field(exclude=True) @@ -180,6 +184,7 @@ class TelegramAdapter(TelegramAPIMixin, InteractionAdapterMixin, abstract_platfo 'bot.muted', 'bot.unmuted', 'platform.specific', + 'interaction.callback', ] def get_supported_apis(self) -> list[str]: @@ -199,14 +204,53 @@ class TelegramAdapter(TelegramAPIMixin, InteractionAdapterMixin, abstract_platfo 'kick_member', 'leave_group', 'call_platform_api', - 'interaction.request', 'send_interaction', + 'update_interaction', 'get_interaction_capabilities', ] def get_interaction_capabilities(self) -> dict[str, typing.Any]: return interaction_delivery_capabilities() + async def send_interaction( + self, + target_type: str, + target_id: str, + request: InteractionRequest, + callback_token: str, + reply_to: str | None = None, + ) -> InteractionHandle: + result = await send_interaction( + self.bot, + { + 'request': request.model_dump(mode='json'), + 'callback_token': callback_token, + 'reply_target': {'target_type': target_type, 'target_id': target_id, 'message_id': reply_to}, + }, + ) + handle = InteractionHandle.model_validate(result) + handle.context['request'] = request.model_dump(mode='json') + handle.context['target_id'] = target_id + return handle + + async def update_interaction( + self, + handle: InteractionHandle, + state: str, + submission: InteractionSubmission | None = None, + ) -> InteractionHandle: + del submission + target_id = str(handle.context['target_id']).split('#', 1)[0] + request = handle.context['request'] + label = {'submitted': 'Submitted', 'expired': 'Expired', 'cancelled': 'Cancelled'}[state] + await self.bot.edit_message_text( + chat_id=int(target_id) if target_id.lstrip('-').isdigit() else target_id, + message_id=handle.message_id, + text=f'{request["title"]}\n\n{label}', + reply_markup=None, + ) + return handle + # ---- Message Send / Reply (preserving original logic) ---- async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): @@ -540,8 +584,6 @@ class TelegramAdapter(TelegramAPIMixin, InteractionAdapterMixin, abstract_platfo params: dict = {}, ) -> dict: """Call a Telegram-specific platform API.""" - if action == 'interaction.request': - return await send_interaction(self.bot, params) handler = PLATFORM_API_MAP.get(action) if handler is None: from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError diff --git a/src/langbot/pkg/platform/adapters/telegram/interaction.py b/src/langbot/pkg/platform/adapters/telegram/interaction.py index 38f5a5584..11eb7e706 100644 --- a/src/langbot/pkg/platform/adapters/telegram/interaction.py +++ b/src/langbot/pkg/platform/adapters/telegram/interaction.py @@ -110,11 +110,11 @@ async def send_interaction(bot: telegram.Bot, params: dict[str, typing.Any]) -> reply_target = params.get('reply_target') callback_token = str(params.get('callback_token') or '') if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token: - raise ValueError('interaction.request requires request, reply_target, and callback_token') + raise ValueError('send_interaction requires request, reply_target, and callback_token') target_id = str(reply_target.get('target_id') or '') if not target_id: - raise ValueError('interaction.request has no target_id') + raise ValueError('send_interaction has no target_id') chat_id_text, _, thread_id_text = target_id.partition('#') chat_id: int | str = int(chat_id_text) if chat_id_text.lstrip('-').isdigit() else chat_id_text message_thread_id = int(thread_id_text) if thread_id_text.isdigit() else None @@ -135,7 +135,7 @@ async def send_interaction(bot: telegram.Bot, params: dict[str, typing.Any]) -> def interaction_event_from_update( update: telegram.Update, parsed: dict[str, typing.Any], -) -> platform_events.PlatformSpecificEvent: +) -> platform_events.InteractionCallbackEvent: """Convert a trusted callback shape into the Host interaction event.""" query = update.callback_query if query is None or query.message is None or query.from_user is None: @@ -153,11 +153,13 @@ def interaction_event_from_update( 'target_id': target_id, 'display_text': 'submitted', } - return platform_events.PlatformSpecificEvent( - type='platform.specific', + return platform_events.InteractionCallbackEvent( timestamp=time.time(), adapter_name='telegram-omni', - action='interaction.submitted', - data=data, + callback_token=str(parsed.get('callback_token') or ''), + actor_id=data['actor_id'], + target_type=target_type, + target_id=target_id, + raw_submission=parsed, source_platform_object=update, ) diff --git a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml index a0e60a5d9..2d0facf19 100644 --- a/src/langbot/pkg/platform/adapters/telegram/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/telegram/manifest.yaml @@ -57,6 +57,7 @@ spec: - bot.muted - bot.unmuted - platform.specific + - interaction.callback supported_apis: required: @@ -76,8 +77,8 @@ spec: - kick_member - leave_group - call_platform_api - - interaction.request - send_interaction + - update_interaction - get_interaction_capabilities platform_specific_apis: diff --git a/src/langbot/pkg/platform/adapters/wecombot/adapter.py b/src/langbot/pkg/platform/adapters/wecombot/adapter.py index a73a1e3a4..8222bec3e 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/adapter.py +++ b/src/langbot/pkg/platform/adapters/wecombot/adapter.py @@ -14,7 +14,6 @@ from langbot.libs.wecom_ai_bot_api.api import WecomBotClient from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient from langbot.pkg.platform.adapters.wecombot.api_impl import WecomBotAPIMixin -from langbot.pkg.platform.adapters.interaction_api import InteractionAdapterMixin from langbot.pkg.platform.adapters.wecombot.event_converter import WecomBotEventConverter from langbot.pkg.platform.adapters.wecombot.message_converter import WecomBotMessageConverter from langbot.pkg.platform.adapters.wecombot.platform_api import PLATFORM_API_MAP @@ -30,9 +29,14 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) -class WecomBotAdapter(WecomBotAPIMixin, InteractionAdapterMixin, abstract_platform_adapter.AbstractPlatformAdapter): +class WecomBotAdapter(WecomBotAPIMixin, abstract_platform_adapter.AbstractPlatformAdapter): bot: typing.Any = pydantic.Field(exclude=True) message_converter: WecomBotMessageConverter = WecomBotMessageConverter() @@ -110,6 +114,7 @@ class WecomBotAdapter(WecomBotAPIMixin, InteractionAdapterMixin, abstract_platfo 'wecombot.enter_chat', 'wecombot.template_card_event', 'platform.specific', + 'interaction.callback', ] def get_supported_apis(self) -> list[str]: @@ -127,8 +132,6 @@ class WecomBotAdapter(WecomBotAPIMixin, InteractionAdapterMixin, abstract_platfo if not self.config.get('enable-webhook', False) and hasattr(self.bot, 'send_template_card'): apis.extend( [ - 'interaction.request', - 'interaction.acknowledge', 'send_interaction', 'update_interaction', 'get_interaction_capabilities', @@ -139,6 +142,46 @@ class WecomBotAdapter(WecomBotAPIMixin, InteractionAdapterMixin, abstract_platfo def get_interaction_capabilities(self) -> dict[str, typing.Any]: return interaction_delivery_capabilities() + async def send_interaction( + self, + target_type: str, + target_id: str, + request: InteractionRequest, + callback_token: str, + reply_to: str | None = None, + ) -> InteractionHandle: + result = await send_interaction( + self, + { + 'request': request.model_dump(mode='json'), + 'callback_token': callback_token, + 'reply_target': {'target_type': target_type, 'target_id': target_id, 'message_id': reply_to}, + }, + ) + handle = InteractionHandle.model_validate(result) + handle.context['request'] = request.model_dump(mode='json') + return handle + + async def update_interaction( + self, + handle: InteractionHandle, + state: str, + submission: InteractionSubmission | None = None, + ) -> InteractionHandle: + result = await acknowledge_interaction( + self, + { + 'update_target': handle.model_dump(mode='json'), + 'request': handle.context['request'], + 'submission': submission.model_dump(mode='json') if submission else {}, + 'callback_context': handle.context.get('callback_context') or {}, + 'state': state, + }, + ) + updated = InteractionHandle.model_validate(result) + updated.context.update(handle.context) + return updated + @staticmethod def _plain_message(text: str) -> platform_message.MessageChain: return platform_message.MessageChain([platform_message.Plain(text=text)]) @@ -299,10 +342,6 @@ class WecomBotAdapter(WecomBotAPIMixin, InteractionAdapterMixin, abstract_platfo raise RuntimeError('WeCom rejected the new interaction reply stream') async def call_platform_api(self, action: str, params: dict = {}) -> dict: - if action == 'interaction.request' and 'interaction.request' in self.get_supported_apis(): - return await send_interaction(self, params) - if action == 'interaction.acknowledge' and 'interaction.acknowledge' in self.get_supported_apis(): - return await acknowledge_interaction(self, params) handler = PLATFORM_API_MAP.get(action) if handler is None: raise NotSupportedError(f'call_platform_api:{action}') diff --git a/src/langbot/pkg/platform/adapters/wecombot/interaction.py b/src/langbot/pkg/platform/adapters/wecombot/interaction.py index 2efe2194b..ec7f7ea6c 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/interaction.py +++ b/src/langbot/pkg/platform/adapters/wecombot/interaction.py @@ -106,7 +106,7 @@ async def send_interaction(adapter: typing.Any, params: dict[str, typing.Any]) - reply_target = params.get('reply_target') callback_token = str(params.get('callback_token') or '') if not isinstance(request, dict) or not isinstance(reply_target, dict) or not callback_token: - raise ValueError('interaction.request requires request, reply_target, and callback_token') + raise ValueError('send_interaction requires request, reply_target, and callback_token') target_type = str(reply_target.get('target_type') or '') target_id = str(reply_target.get('target_id') or '') card = build_interaction_card(request, callback_token) @@ -144,21 +144,22 @@ async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing. target = params.get('update_target') callback = params.get('callback_context') if not all(isinstance(value, dict) for value in (request, submission, target, callback)): - raise ValueError('interaction.acknowledge requires request, submission, update_target, and callback_context') - task_id = str(callback.get('task_id') or '') + raise ValueError('update_interaction requires request, submission, update_target, and callback_context') + state = params.get('state', 'submitted') + task_id = str(callback.get('task_id') or target.get('task_id') or '') req_id = str(callback.get('req_id') or '') if not req_id or not task_id or task_id != target.get('task_id') or target.get('rich') is not True: raise ValueError('WeCom interaction callback does not match the delivered card') - card = build_interaction_card(request, 'submitted') + card = build_interaction_card(request, state) if card is None: raise ValueError('WeCom interaction has no card to update') updated = card['template_card'] updated['task_id'] = task_id - action_id = submission.get('action_id') + action_id = submission.get('action_id') if state == 'submitted' else None if updated['card_type'] == 'button_interaction': for index, action in enumerate((request.get('actions') or [])[:6]): - updated['button_list'][index]['key'] = 'submitted' + updated['button_list'][index]['key'] = state if isinstance(action, dict) and action.get('id') == action_id: button = updated['button_list'][index] button['text'] = f'✓ {button["text"]}' @@ -172,8 +173,10 @@ async def acknowledge_interaction(adapter: typing.Any, params: dict[str, typing. updated['select_list'][0]['selected_id'] = f'opt_{index + 1}' break updated['select_list'][0]['disable'] = True - updated['submit_button']['text'] = '✓ Submitted' - updated['submit_button']['key'] = 'submitted' + updated['submit_button']['text'] = {'submitted': 'Submitted', 'expired': 'Expired', 'cancelled': 'Cancelled'}[ + state + ] + updated['submit_button']['key'] = state raw = await adapter.bot.update_template_card(req_id, updated) if not isinstance(raw, dict) or raw.get('errcode', 0) != 0: detail = raw.get('errmsg', raw.get('errcode')) if isinstance(raw, dict) else 'no acknowledgement' @@ -274,7 +277,7 @@ def _selected_option_ref(payload: dict[str, typing.Any]) -> int: def interaction_event_from_native( event: WecomBotEvent, -) -> platform_events.PlatformSpecificEvent | None: +) -> platform_events.InteractionCallbackEvent | None: callback_key = _callback_key(_template_card_event(event)) if not callback_key.startswith('lbi:'): return None @@ -285,19 +288,14 @@ def interaction_event_from_native( parsed['option_ref'] = _selected_option_ref(_template_card_event(event)) target_type = 'group' if event.type == 'group' or event.chatid else 'person' target_id = str(event.chatid or event.userid or '') - return platform_events.PlatformSpecificEvent( - type='platform.specific', + return platform_events.InteractionCallbackEvent( adapter_name='wecombot-omni', - action='interaction.submitted', - data={ - **parsed, - 'actor_id': str(event.userid or ''), - 'target_type': target_type, - 'target_id': target_id, - 'display_text': 'submitted', - 'callback_req_id': str(event.get('req_id') or ''), - 'callback_task_id': task_id, - }, + callback_token=str(parsed.get('callback_token') or ''), + actor_id=str(event.userid or ''), + target_type=target_type, + target_id=target_id, + callback_context={'req_id': str(event.get('req_id') or ''), 'task_id': task_id}, + raw_submission=parsed, timestamp=time.time(), source_platform_object=event, ) diff --git a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml index bcf1fb12a..e1829451e 100644 --- a/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml +++ b/src/langbot/pkg/platform/adapters/wecombot/manifest.yaml @@ -177,6 +177,7 @@ spec: - wecombot.enter_chat - wecombot.template_card_event - platform.specific + - interaction.callback supported_apis: required: @@ -190,8 +191,6 @@ spec: - get_group_member_info - get_group_member_list - call_platform_api - - interaction.request - - interaction.acknowledge - send_interaction - update_interaction - get_interaction_capabilities diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index 133ac148d..8800905f8 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -855,7 +855,7 @@ class RuntimeBot: adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, supported_apis: list[str], ) -> dict[str, typing.Any] | None: - if 'interactions' not in DeliveryContext.model_fields or 'interaction.request' not in supported_apis: + if 'interactions' not in DeliveryContext.model_fields or 'send_interaction' not in supported_apis: return None get_capabilities = getattr(adapter, 'get_interaction_capabilities', None) if not callable(get_capabilities): @@ -900,8 +900,7 @@ class RuntimeBot: state_policy=StatePolicy(state_scopes=['conversation', 'actor', 'subject', 'runner']), delivery_policy=DeliveryPolicy( enable_streaming=agent.get('kind') == 'agent' and event_type == 'message.received', - enable_reply=agent.get('kind') == 'agent' - and event_type in {'message.received', 'interaction.submitted'}, + enable_reply=agent.get('kind') == 'agent' and event_type == 'message.received', enable_interactions=agent.get('kind') != 'event_processor', ), agent_id=agent.get('uuid'), @@ -1032,29 +1031,12 @@ class RuntimeBot: ) -> None: results: list[typing.Any] = [] try: - if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted': - callback_data = event.data if isinstance(event.data, dict) else {} - event = platform_events.InteractionCallbackEvent( - callback_token=str(callback_data.get('callback_token') or ''), - values=callback_data.get('values') if isinstance(callback_data.get('values'), dict) else {}, - action_id=callback_data.get('action_id'), - actor_id=str(callback_data.get('actor_id') or callback_data.get('user_id') or '') or None, - target_type=callback_data.get('target_type'), - target_id=callback_data.get('target_id') or callback_data.get('chat_id'), - raw_submission=callback_data, - timestamp=event.timestamp, - bot_uuid=event.bot_uuid, - adapter_name=event.adapter_name, - source_platform_object=event.source_platform_object, - ) await self._record_adapter_event(event, adapter) await self._persist_monitoring_ingress(event, adapter, execution_id) primary_handler = ( self._handle_interaction_submission if isinstance(event, platform_events.InteractionCallbackEvent) - or isinstance(event, platform_events.PlatformSpecificEvent) - and event.action == 'interaction.submitted' else self._dispatch_eba_event_to_processor ) subscriptions = self._get_event_bindings_from_value(getattr(self.bot_entity, 'plugin_processors', [])) @@ -1344,58 +1326,17 @@ class RuntimeBot: try: with trace_mod.scope(route_ref=self._route_ref(event_binding, target_type, target_uuid)): - if ( - target_type == 'agent' - and isinstance(event, platform_events.MessageReceivedEvent) - and event.adapter_name == 'wecombot-omni' - and event.message_chain - and all( - isinstance( - component, - ( - platform_message.Source, - platform_message.Plain, - platform_message.At, - platform_message.AtAll, - ), - ) - for component in event.message_chain - ) + if target_type == 'agent' and await self.ap.agent_run_orchestrator.try_claim_steering_from_event( + envelope, binding, self.execution_context ): - answer = ''.join( - component.text - for component in event.message_chain - if isinstance(component, platform_message.Plain) + return await self._record_event_route_trace( + event_type=event_type, + status='delivered', + binding=event_binding, + target_type=target_type, + target_uuid=target_uuid, + text=f'Event {event_type} queued as steering', ) - record = await self.ap.agent_run_orchestrator.interaction_manager.store.consume_text_reply( - workspace_id=self.execution_context.workspace_uuid, - bot_id=self.bot_entity.uuid, - conversation_id=envelope.conversation_id or '', - actor_id=envelope.actor.actor_id if envelope.actor else '', - binding_id=binding.binding_id, - runner_id=binding.runner_id, - processor_id=target_uuid, - text=answer, - message_id=str(event.message_id or self._extract_message_id(event.message_chain)), - message_time=event.timestamp, - ) - if record is not None: - if not record.get('duplicate_text_reply'): - await self._resume_agent_interaction( - record, - event, - adapter, - envelope.actor.actor_id if envelope.actor else None, - execution_id, - ) - return await self._record_event_route_trace( - event_type=event_type, - status='delivered', - binding=event_binding, - target_type=target_type, - target_uuid=target_uuid, - text=f'Event {event_type} handled text interaction reply', - ) from .processing_indicator import processing_indicator reply_type, reply_id = (None, None) @@ -1587,262 +1528,58 @@ class RuntimeBot: async def _handle_interaction_submission( self, - event: platform_events.PlatformSpecificEvent | platform_events.InteractionCallbackEvent, + event: platform_events.InteractionCallbackEvent, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, execution_id: str | None = None, ) -> None: - """Consume a platform interaction callback and resume its original processor.""" - data = ( - event.data - if isinstance(event, platform_events.PlatformSpecificEvent) and isinstance(event.data, dict) - else { - **event.raw_submission, - 'callback_token': event.callback_token, - 'action_id': event.action_id, - 'values': event.values, - 'actor_id': event.actor_id, - 'target_type': event.target_type, - 'target_id': event.target_id, - } - ) - callback_token = str(data.get('callback_token') or '') - actor_id = str(data.get('actor_id') or data.get('user_id') or '') or None - target_type = str(data.get('target_type') or '') or None - target_id = str(data.get('target_id') or data.get('chat_id') or '') or None + """Validate a callback and wake the Runner waiting in the original run.""" + del execution_id + manager = self.ap.agent_run_orchestrator.interaction_manager + target_type = event.target_type + target_id = event.target_id conversation_id = f'{target_type}_{target_id}' if target_type and target_id else None - submission = { - 'interaction_id': data.get('interaction_id'), - 'action_id': data.get('action_id'), - 'values': data.get('values') if isinstance(data.get('values'), dict) else {}, - 'submitted_at': int(event.timestamp) if event.timestamp else None, + submission: dict[str, typing.Any] = { + 'action_id': event.action_id, + 'values': event.values, } for ref_name in ('action_ref', 'field_ref', 'option_ref'): - if data.get(ref_name) is not None: - submission[ref_name] = data[ref_name] + value = event.raw_submission.get(ref_name) + if value is not None: + submission[ref_name] = value try: - record = await self.ap.agent_run_orchestrator.interaction_manager.consume_callback( - callback_token=callback_token, + record = await manager.consume_callback( + callback_token=event.callback_token, submission=submission, bot_id=self.bot_entity.uuid, conversation_id=conversation_id, - actor_id=actor_id, + actor_id=event.actor_id, ) except InteractionAlreadySubmittedError: - # Platform retries and repeated clicks are successful no-ops. The - # first callback remains the only turn allowed to resume execution. return except InteractionExpiredError: - stale = await self.ap.agent_run_orchestrator.interaction_manager.store.get_by_callback_token(callback_token) + stale = await manager.store.get_by_callback_token(event.callback_token) delivery_result = stale.get('delivery_result') if stale else None if isinstance(delivery_result, dict) and callable(getattr(adapter, 'update_interaction', None)): try: handle = InteractionHandle.model_validate(delivery_result) - handle.context['callback_context'] = { - 'req_id': data.get('callback_req_id'), - 'task_id': data.get('callback_task_id'), - } - await adapter.update_interaction(handle, 'cancelled') + handle.context.setdefault('request', stale.get('request') or {}) + handle.context['callback_context'] = event.callback_context + await adapter.update_interaction(handle, 'expired') except Exception as exc: await self.logger.warning(f'Failed to invalidate stale interaction card: {exc}') return - wake_waiter = getattr(self.ap.agent_run_orchestrator.interaction_manager, 'wake_waiter', None) - if callable(wake_waiter) and wake_waiter( - record, - actor_id, - { - 'req_id': data.get('callback_req_id'), - 'task_id': data.get('callback_task_id'), - }, - ): - return - if record['processor_type'] not in {'agent', 'pipeline'}: - raise ValueError(f'Unsupported interaction processor type: {record["processor_type"]}') - from ..telemetry import trace as trace_mod - # Only a validated callback resolving an original processor is eligible. - # Admit before acknowledgement and target lookup to preserve failures. - trace_mod.admit() - callback_context = None - if getattr(event, 'adapter_name', None) == 'wecombot-omni': - callback_context = { - 'req_id': data.get('callback_req_id'), - 'task_id': data.get('callback_task_id'), - } - await self.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission( - record, adapter, callback_context - ) - - if record['processor_type'] == 'agent': - await self._resume_agent_interaction(record, event, adapter, actor_id, execution_id) - return - - pipeline = await self.ap.pipeline_service.get_pipeline( - self.execution_context, - record['processor_id'], - include_secret=True, - ) - if not pipeline: - raise ValueError(f'Interaction target Pipeline is unavailable: {record["processor_id"]}') - current_runner_id = RunnerConfigResolver.resolve_runner_id(pipeline.get('config') or {}) - if current_runner_id != record['runner_id']: - raise ValueError('Interaction target Pipeline runner changed after the request was created') - - message_components: list[platform_message.MessageComponent] = [] - callback_message_id = data.get('message_id') - if callback_message_id: - message_components.append( - platform_message.Source( - id=str(callback_message_id), - time=int(event.timestamp) if event.timestamp else int(time.time()), - ) - ) - message_components.append( - platform_message.Plain(text=str(data.get('display_text') or data.get('action_id') or 'submitted')) - ) - message_chain = platform_message.MessageChain(message_components) - delivery_target = record.get('delivery_target') or {} - original_target_type = delivery_target.get('target_type') - original_target_id = delivery_target.get('target_id') - if original_target_type == 'group': - group = platform_entities.Group( - id=original_target_id, - name='', - permission=platform_entities.Permission.Member, - ) - message_event: platform_events.MessageEvent = platform_events.GroupMessage( - sender=platform_entities.GroupMember( - id=actor_id or '', - member_name='', - permission=platform_entities.Permission.Member, - group=group, - ), - message_chain=message_chain, - time=event.timestamp, - source_platform_object=event.source_platform_object, - ) - else: - message_event = platform_events.FriendMessage( - sender=platform_entities.Friend(id=actor_id or '', nickname='', remark=''), - message_chain=message_chain, - time=event.timestamp, - source_platform_object=event.source_platform_object, - ) - - launcher_type = ( - provider_session.LauncherTypes.GROUP - if original_target_type == 'group' - else provider_session.LauncherTypes.PERSON - ) - await self.ap.msg_aggregator.add_message( - bot_uuid=self.bot_entity.uuid, - launcher_type=launcher_type, - launcher_id=original_target_id or target_id or actor_id or '', - sender_id=actor_id or '', - message_event=message_event, - message_chain=message_chain, - adapter=adapter, - pipeline_uuid=record['processor_id'], - routed_by_rule=True, - variables={'_interaction_submission': record['submission']}, - execution_context=self.execution_context, - ) - - async def _resume_agent_interaction( - self, - record: dict[str, typing.Any], - event: platform_events.EBAEvent, - adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, - actor_id: str | None, - execution_id: str | None = None, - ) -> None: - """Resume the exact independent Agent that produced an interaction.""" - agent = await self.ap.agent_service.get_agent( - self.execution_context, - record['processor_id'], - ) - if not agent or agent.get('kind') != 'agent': - raise ValueError(f'Interaction target Agent is unavailable: {record["processor_id"]}') - - binding = self._agent_product_to_binding( - agent, - {'id': record['binding_id']}, - 'interaction.submitted', - self.bot_entity.uuid, - ) - if binding is None or binding.runner_id != record['runner_id']: - raise ValueError('Interaction target Agent runner changed after the request was created') - binding.binding_id = record['binding_id'] - - submission = record['submission'] - event_data = getattr(event, 'data', None) - values = (submission or {}).get('values') or {} - text_answer = next(iter(values.values()), None) if len(values) == 1 else None - display_text = str( - (submission or {}).get('action_id') - or (event_data if isinstance(event_data, dict) else {}).get('display_text') - or text_answer - or 'submitted' - ) - input_data: dict[str, typing.Any] = { - 'text': display_text, - 'contents': [{'type': 'text', 'text': display_text}], - 'attachments': [], - } - if 'interaction' in AgentInput.model_fields: - input_data['interaction'] = submission - - delivery_target = record.get('delivery_target') or {} - supported_apis = self._get_adapter_supported_apis(adapter) - delivery_data: dict[str, typing.Any] = { - 'surface': 'platform', - 'reply_target': delivery_target, - 'supports_streaming': False, - 'supports_edit': 'edit_message' in supported_apis, - 'supports_reaction': bool({'add_reaction', 'remove_reaction'} & set(supported_apis)), - 'platform_capabilities': { - 'adapter': adapter.__class__.__name__, - 'event_type': 'interaction.submitted', - 'supported_apis': supported_apis, - }, - } - interaction_capabilities = self._get_adapter_interaction_capabilities(adapter, supported_apis) - if interaction_capabilities is not None: - delivery_data['interactions'] = interaction_capabilities - - envelope = AgentEventEnvelope( - event_id=execution_id or f'interaction:{record["id"]}:{uuid.uuid4()}', - event_type='interaction.submitted', - event_time=int(event.timestamp) if event.timestamp else int(time.time()), - source='platform', - source_event_type='interaction.submitted', - bot_id=self.bot_entity.uuid, - workspace_id=record.get('workspace_id'), - conversation_id=record.get('conversation_id'), - thread_id=record.get('thread_id'), - actor=ActorContext(actor_type='user', actor_id=actor_id), - subject=SubjectContext( - subject_type='interaction', - subject_id=record['interaction_id'], - data={'action_id': (submission or {}).get('action_id')}, - ), - input=AgentInput.model_validate(input_data), - delivery=DeliveryContext.model_validate(delivery_data), - raw_ref=RawEventRef(ref_id=f'interaction:{record["id"]}', storage_key=None), - data={'interaction': submission}, - ) - - async for _ in self.ap.agent_run_orchestrator.run( - envelope, - binding, - adapter_context={ - '_delivery_adapter': adapter, - '_platform_event': event, - '_execution_context': self.execution_context, - }, - ): - # The orchestrator delivers Direct Agent output unless a platform action already did. - pass + if not manager.wake_waiter(record, event.actor_id, event.callback_context): + delivery_result = record.get('delivery_result') + if isinstance(delivery_result, dict) and callable(getattr(adapter, 'update_interaction', None)): + try: + handle = InteractionHandle.model_validate(delivery_result) + handle.context.setdefault('request', record.get('request') or {}) + handle.context['callback_context'] = event.callback_context + await adapter.update_interaction(handle, 'cancelled') + except Exception as exc: + await self.logger.warning(f'Failed to invalidate orphaned interaction card: {exc}') async def _dispatch_eba_message_to_pipeline( self, diff --git a/tests/unit_tests/agent/test_context_validation.py b/tests/unit_tests/agent/test_context_validation.py index bb47a6e12..438a85eed 100644 --- a/tests/unit_tests/agent/test_context_validation.py +++ b/tests/unit_tests/agent/test_context_validation.py @@ -178,22 +178,15 @@ class TestContextValidation: assert validated.input.text == 'Hello world' @pytest.mark.asyncio - async def test_build_context_preserves_interaction_protocol_fields(self): - """Validated submissions and delivery capabilities survive the final context projection.""" + async def test_build_context_preserves_interaction_delivery_capabilities(self): + """Delivery capabilities survive the final context projection.""" from langbot_plugin.api.entities.builtin.runner.interaction import ( InteractionDeliveryCapabilities, - InteractionSubmission, ) mock_app = self._make_mock_app() builder = RunnerContextBuilder(mock_app) event = self._make_event_envelope() - event.event_type = 'interaction.submitted' - event.input.interaction = InteractionSubmission( - interaction_id='form-1', - action_id='approve', - values={'comment': 'looks good'}, - ) event.delivery.interactions = InteractionDeliveryCapabilities( field_types=['text', 'select'], action_styles=['primary'], @@ -213,9 +206,6 @@ class TestContextValidation: ) validated = RunnerContext.model_validate(context_dict) - assert validated.input.interaction is not None - assert validated.input.interaction.interaction_id == 'form-1' - assert validated.input.interaction.values == {'comment': 'looks good'} assert validated.delivery.interactions is not None assert validated.delivery.interactions.field_types == ['text', 'select'] diff --git a/tests/unit_tests/agent/test_event_first_protocol.py b/tests/unit_tests/agent/test_event_first_protocol.py index 2721ff630..a7c7a333e 100644 --- a/tests/unit_tests/agent/test_event_first_protocol.py +++ b/tests/unit_tests/agent/test_event_first_protocol.py @@ -245,8 +245,8 @@ class TestQueryConfigToAgentConfig: assert binding.processor_type == 'pipeline' assert binding.processor_id == mock_query.pipeline_uuid - def test_interaction_submission_projects_control_event(self, mock_query): - """Pipeline callbacks keep structured submission data and match the runner binding.""" + def test_legacy_submission_variable_does_not_create_control_event(self, mock_query): + """Legacy query variables cannot forge an interaction callback.""" mock_query.variables = { '_interaction_submission': { 'interaction_id': 'form-1', @@ -259,10 +259,10 @@ class TestQueryConfigToAgentConfig: agent_config = QueryEntryAdapter.config_to_agent_config(mock_query, 'plugin:langbot-team/DifyAgent/default') binding = AgentBindingResolver().resolve_one(event, [agent_config]) - assert event.event_type == 'interaction.submitted' - assert event.data['interaction']['values'] == {'name': 'Alice'} - assert event.subject.subject_type == 'interaction' - assert agent_config.event_types == ['interaction.submitted'] + assert event.event_type == 'message.received' + assert 'interaction' not in event.data + assert event.subject.subject_type == 'message' + assert agent_config.event_types == ['message.received'] assert binding.processor_type == 'pipeline' def test_resolver_rejects_multiple_matching_agents(self, mock_query): diff --git a/tests/unit_tests/agent/test_interaction_manager.py b/tests/unit_tests/agent/test_interaction_manager.py index a85cf7cf8..7d4133a40 100644 --- a/tests/unit_tests/agent/test_interaction_manager.py +++ b/tests/unit_tests/agent/test_interaction_manager.py @@ -1,851 +1,32 @@ -"""Tests for structured interaction authorization and delivery.""" +"""Blocking interaction and callback authorization tests.""" -from __future__ import annotations - -from types import SimpleNamespace import asyncio import time +from types import SimpleNamespace import pytest -import sqlalchemy as sa from sqlalchemy.ext.asyncio import create_async_engine -from langbot_plugin.api.entities.builtin.runner.delivery import DeliveryContext -from langbot_plugin.api.entities.builtin.runner.event import ActorContext -from langbot_plugin.api.entities.builtin.runner.input import AgentInput from langbot_plugin.api.entities.builtin.runner.interaction import ( InteractionDeliveryCapabilities, InteractionHandle, - InteractionSubmission, -) - -from langbot.pkg.agent.runner.errors import RunnerProtocolError -from langbot.pkg.agent.runner.host_models import ( - AgentBinding, - AgentEventEnvelope, - BindingScope, - DeliveryPolicy, + InteractionRequest, ) from langbot.pkg.agent.runner.interaction_manager import InteractionManager -from langbot.pkg.agent.runner.interaction_store import InteractionStore -from langbot.pkg.entity.persistence.base import Base -from langbot.pkg.entity.persistence.pipeline import LegacyPipeline - -CONFIG = { - 'ai': { - 'runner': {'id': 'plugin:test/ApprovalRunner/default'}, - 'runner_config': {'plugin:test/ApprovalRunner/default': {}}, - } -} - - -def _context(adapter): - conversation = SimpleNamespace(pipeline_uuid='pipeline-1') - query = SimpleNamespace( - pipeline_uuid='pipeline-1', pipeline_config=CONFIG, session=SimpleNamespace(using_conversation=conversation) - ) - return { - '_delivery_adapter': adapter, - '_query': query, - '_pipeline_expected_config': CONFIG, - '_pipeline_conversation': conversation, - } - - -class FakeAdapter: - def __init__(self, *, supports_interactions: bool, fail_updates: bool = False): - self.supports_interactions = supports_interactions - self.fail_updates = fail_updates - self.actions: list[tuple[str, dict]] = [] - self.messages: list[tuple[str, str, object]] = [] - - def get_supported_apis(self): - return ['interaction.request', 'interaction.acknowledge'] if self.supports_interactions else ['send_message'] - - async def call_platform_api(self, action: str, params: dict): - self.actions.append((action, params)) - if action == 'interaction.request' and params.get('update_target') and self.fail_updates: - raise RuntimeError('update unavailable') - update_target = params.get('update_target') or {} - message_id = update_target.get('message_id') or f'message-{len(self.actions)}' - card_id = update_target.get('card_id') or f'card-{len(self.actions)}' - sequence = int(update_target.get('sequence') or 0) + (1 if update_target else 0) - return { - 'ok': True, - 'message_id': message_id, - 'card_id': card_id, - 'sequence': sequence, - 'rich': True, - } - - async def send_message(self, target_type: str, target_id: str, message_chain): - self.messages.append((target_type, target_id, message_chain)) - - -@pytest.fixture -async def store(tmp_path): - engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "manager.db"}', echo=False) - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - await conn.execute( - sa.insert(LegacyPipeline).values( - uuid='pipeline-1', - workspace_uuid='workspace-1', - name='test', - description='', - for_version='4.11', - stages=[], - config=CONFIG, - extensions_preferences={}, - ) - ) - yield InteractionStore(engine) - await engine.dispose() - - -@pytest.fixture -def event(): - return AgentEventEnvelope( - event_id='evt-1', - event_type='message.received', - source='platform', - bot_id='bot-1', - workspace_id='workspace-1', - conversation_id='group_chat-1', - actor=ActorContext(actor_type='user', actor_id='user-1'), - input=AgentInput(text='start'), - delivery=DeliveryContext( - surface='platform', - reply_target={'target_type': 'group', 'target_id': 'chat-1'}, - ), - ) - - -@pytest.fixture -def binding(): - return AgentBinding( - binding_id='binding-1', - scope=BindingScope(scope_type='agent', scope_id='pipeline-1'), - runner_id='plugin:test/ApprovalRunner/default', - delivery_policy=DeliveryPolicy(enable_interactions=True), - processor_type='pipeline', - processor_id='pipeline-1', - ) - - -def _descriptor(*, permitted: bool = True): - return SimpleNamespace( - id='plugin:test/ApprovalRunner/default', - capabilities=SimpleNamespace(interactions=permitted), - permissions=SimpleNamespace(interactions=['request'] if permitted else []), - ) - - -def _result(): - return { - 'type': 'action.requested', - 'data': { - 'action': 'interaction.requested', - 'target': {'target_type': 'person', 'target_id': 'attacker-selected'}, - 'payload': { - 'interaction_id': 'form-1', - 'kind': 'choice', - 'title': 'Approve request?', - 'actions': [ - {'id': 'approve', 'label': 'Approve', 'style': 'primary'}, - {'id': 'reject', 'label': 'Reject', 'style': 'danger'}, - ], - 'fallback_text': 'Reply approve or reject.', - }, - }, - } - - -@pytest.mark.asyncio -async def test_structured_delivery_uses_frozen_target_and_persists_request(store, event, binding): - adapter = FakeAdapter(supports_interactions=True) - manager = InteractionManager(SimpleNamespace(), store=store) - - consumed = await manager.handle_result( - result_dict=_result(), - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(adapter), - ) - - assert consumed is True - assert adapter.actions[0][0] == 'interaction.request' - params = adapter.actions[0][1] - assert params['reply_target'] == {'target_type': 'group', 'target_id': 'chat-1'} - assert params['callback_token'] - assert 'attacker-selected' not in str(params) - - record = await store.get_request('run-1', 'form-1') - assert record is not None - assert record['processor_type'] == 'pipeline' - assert record['processor_id'] == 'pipeline-1' - assert record['actor_id'] == 'user-1' - assert record['expires_at'] is not None - assert 0 < record['expires_at'] - time.time() <= 30 * 60 - assert record['delivery_result']['message_id'] == 'message-1' - - -@pytest.mark.asyncio -async def test_text_reply_consumes_latest_scoped_plain_form_once(store): - async def create(interaction_id, *, field_type='textarea', rich=False): - await store.create_request( - interaction_id=interaction_id, - run_id=f'run-{interaction_id}', - binding_id='binding-1', - runner_id='runner-1', - processor_type='agent', - processor_id='agent-1', - request={ - 'fields': [{'id': 'question', 'type': field_type}], - 'actions': [], - }, - delivery_target={'target_type': 'person', 'target_id': 'user-1'}, - workspace_id='workspace-1', - bot_id='bot-1', - conversation_id='person_user-1', - actor_id='user-1', - expires_at=time.time() + 600, - ) - await store.record_delivery_success(f'run-{interaction_id}', interaction_id, {'ok': True, 'rich': rich}) - - async def reply(**overrides): - scope = dict( - workspace_id='workspace-1', - bot_id='bot-1', - conversation_id='person_user-1', - actor_id='user-1', - binding_id='binding-1', - runner_id='runner-1', - processor_id='agent-1', - text=' answer ', - message_id='message-1', - ) - scope.update(overrides) - return await store.consume_text_reply(**scope) - - await create('old') - await create('new') - assert await reply(actor_id='user-2') is None - assert await reply(conversation_id='person_user-2') is None - assert await reply(workspace_id='workspace-2') is None - assert await reply(text=' ') is None - submitted = await reply() - assert submitted['interaction_id'] == 'new' - assert submitted['submission']['values'] == {'question': 'answer'} - assert (await reply())['duplicate_text_reply'] is True - assert await reply(message_id='message-2') is None - assert (await store.get_request('run-old', 'old'))['status'] == 'pending' - - await create('rich', rich=True) - assert await reply(message_id='message-3') is None - await create('select', field_type='select') - assert await reply(message_id='message-4') is None - - -@pytest.mark.asyncio -async def test_agent_tool_card_uses_host_token_and_persists_resume_target(store, event): - adapter = FakeAdapter(supports_interactions=True) - manager = InteractionManager(SimpleNamespace(), store=store) - event.delivery.interactions = InteractionDeliveryCapabilities( - field_types=['text', 'textarea', 'select', 'number'], - action_styles=['default', 'primary', 'danger'], - supports_updates=True, - max_fields=1, - ) - session = { - 'run_id': 'agent-run-1', - 'runner_id': 'plugin:test/CardAgent/default', - 'authorization': { - 'binding_id': 'agent-binding-1', - 'processor_type': 'agent', - 'processor_id': 'agent-1', - 'interactions_enabled': True, - 'bot_id': 'bot-1', - 'workspace_id': 'workspace-1', - 'thread_id': None, - 'platform_context': { - 'actor': {'actor_type': 'user', 'actor_id': 'user-1'}, - 'delivery': event.delivery.model_dump(mode='json'), - }, - }, - } - request = { - 'interaction_id': 'card-host-generated', - 'kind': 'choice', - 'title': 'Choose priority', - 'fields': [ - { - 'id': 'priority', - 'label': 'Priority', - 'type': 'select', - 'options': [{'value': 'high', 'label': 'High'}], - } - ], - 'actions': [], - 'expires_at': int(time.time()) + 600, - 'fallback_text': 'Choose priority', - } - - result = await manager.present_tool_request(request_payload=request, session=session, adapter=adapter) - - assert result['status'] == 'pending' - assert 'callback_token' not in result - params = adapter.actions[0][1] - assert params['callback_token'] - assert params['reply_target'] == {'target_type': 'group', 'target_id': 'chat-1'} - record = await store.get_request('agent-run-1', 'card-host-generated') - assert record is not None - assert record['processor_type'] == 'agent' - assert record['processor_id'] == 'agent-1' - assert record['binding_id'] == 'agent-binding-1' - assert record['conversation_id'] == 'group_chat-1' - assert record['actor_id'] == 'user-1' - - -@pytest.mark.asyncio -async def test_pipeline_tool_card_preserves_pipeline_authority(store, event): - adapter = FakeAdapter(supports_interactions=True) - manager = InteractionManager(SimpleNamespace(), store=store) - event.delivery.interactions = InteractionDeliveryCapabilities( - field_types=['select'], - action_styles=['default', 'primary', 'danger'], - supports_updates=True, - max_fields=1, - ) - conversation = SimpleNamespace(pipeline_uuid='pipeline-1') - execution_query = SimpleNamespace( - pipeline_uuid='pipeline-1', - pipeline_config=CONFIG, - session=SimpleNamespace(using_conversation=conversation), - ) - session = { - 'run_id': 'pipeline-tool-run-1', - 'runner_id': 'plugin:test/ApprovalRunner/default', - 'execution_query': execution_query, - 'authorization': { - 'binding_id': 'pipeline-binding-1', - 'processor_type': 'pipeline', - 'processor_id': 'pipeline-1', - 'interactions_enabled': True, - 'bot_id': 'bot-1', - 'workspace_id': 'workspace-1', - 'thread_id': None, - 'platform_context': { - 'actor': {'actor_type': 'user', 'actor_id': 'user-1'}, - 'delivery': event.delivery.model_dump(mode='json'), - }, - }, - } - request = { - 'interaction_id': 'pipeline-card-host-generated', - 'kind': 'choice', - 'title': 'Continue?', - 'actions': [ - {'id': 'confirm', 'label': 'Confirm', 'style': 'primary'}, - {'id': 'cancel', 'label': 'Cancel', 'style': 'default'}, - ], - 'fallback_text': 'Reply confirm or cancel.', - } - - result = await manager.present_tool_request(request_payload=request, session=session, adapter=adapter) - - assert result['status'] == 'pending' - record = await store.get_request('pipeline-tool-run-1', 'pipeline-card-host-generated') - assert record is not None - assert record['processor_type'] == 'pipeline' - assert record['processor_id'] == 'pipeline-1' - assert record['binding_id'] == 'pipeline-binding-1' - - -@pytest.mark.asyncio -async def test_continuous_interaction_reuses_submitted_platform_presentation(store, event, binding): - adapter = FakeAdapter(supports_interactions=True) - manager = InteractionManager(SimpleNamespace(), store=store) - event.delivery.interactions = InteractionDeliveryCapabilities(supports_updates=True) - - await manager.handle_result( - result_dict=_result(), - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(adapter), - ) - first_token = adapter.actions[0][1]['callback_token'] - submitted = await manager.consume_callback( - callback_token=first_token, - submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - await manager.acknowledge_submission(submitted, adapter) - - event.event_type = 'interaction.submitted' - event.input.interaction = InteractionSubmission( - interaction_id='form-1', - action_id='approve', - ) - second_result = _result() - second_result['data']['payload']['interaction_id'] = 'form-2' - await manager.handle_result( - result_dict=second_result, - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-2', - adapter_context=_context(adapter), - ) - - assert [action for action, _ in adapter.actions] == [ - 'interaction.request', - 'interaction.acknowledge', - 'interaction.request', - ] - update_params = adapter.actions[-1][1] - assert update_params['update_target']['message_id'] == 'message-1' - assert update_params['update_target']['sequence'] == 1 - second_record = await store.get_request('run-2', 'form-2') - assert second_record['replaces_interaction_id'] == 'form-1' - assert second_record['delivery_result']['message_id'] == 'message-1' - assert second_record['delivery_result']['sequence'] == 2 - - -@pytest.mark.asyncio -async def test_interaction_update_failure_falls_back_to_new_presentation(store, event, binding): - adapter = FakeAdapter(supports_interactions=True, fail_updates=True) - manager = InteractionManager(SimpleNamespace(), store=store) - event.delivery.interactions = InteractionDeliveryCapabilities(supports_updates=True) - - await manager.handle_result( - result_dict=_result(), - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(adapter), - ) - first_token = adapter.actions[0][1]['callback_token'] - await manager.consume_callback( - callback_token=first_token, - submission={'interaction_id': 'form-1', 'action_id': 'approve', 'values': {}}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - event.input.interaction = InteractionSubmission(interaction_id='form-1', action_id='approve') - second_result = _result() - second_result['data']['payload']['interaction_id'] = 'form-2' - await manager.handle_result( - result_dict=second_result, - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-2', - adapter_context=_context(adapter), - ) - - update_attempt = adapter.actions[-2][1] - fallback_attempt = adapter.actions[-1][1] - assert update_attempt['update_target']['message_id'] == 'message-1' - assert 'update_target' not in fallback_attempt - assert fallback_attempt['callback_token'] == update_attempt['callback_token'] - - -@pytest.mark.asyncio -async def test_callback_scope_uses_frozen_delivery_conversation(store, event, binding): - event.conversation_id = 'runner-owned-external-conversation' - adapter = FakeAdapter(supports_interactions=True) - manager = InteractionManager(SimpleNamespace(), store=store) - - await manager.handle_result( - result_dict=_result(), - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(adapter), - ) - - record = await store.get_request('run-1', 'form-1') - assert record['conversation_id'] == 'group_chat-1' - - -@pytest.mark.asyncio -async def test_adapter_without_interactions_receives_fallback_text(store, event, binding): - adapter = FakeAdapter(supports_interactions=False) - manager = InteractionManager(SimpleNamespace(), store=store) - - assert await manager.handle_result( - result_dict=_result(), - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(adapter), - ) - - assert adapter.actions == [] - target_type, target_id, message_chain = adapter.messages[0] - assert (target_type, target_id) == ('group', 'chat-1') - assert str(message_chain) == 'Reply approve or reject.' - - -@pytest.mark.asyncio -async def test_runner_without_interaction_permission_is_rejected(store, event, binding): - manager = InteractionManager(SimpleNamespace(), store=store) - - with pytest.raises(RunnerProtocolError, match='did not declare'): - await manager.handle_result( - result_dict=_result(), - event=event, - binding=binding, - descriptor=_descriptor(permitted=False), - run_id='run-1', - adapter_context=_context(FakeAdapter(supports_interactions=True)), - ) - - assert await store.get_request('run-1', 'form-1') is None - - -@pytest.mark.asyncio -async def test_binding_policy_can_disable_interactions(store, event, binding): - manager = InteractionManager(SimpleNamespace(), store=store) - binding.delivery_policy.enable_interactions = False - - with pytest.raises(RunnerProtocolError, match='delivery policy'): - await manager.handle_result( - result_dict=_result(), - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(FakeAdapter(supports_interactions=True)), - ) - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ('expires_at', 'error'), - [ - (lambda now: 0, 'already expired'), - (lambda now: now - 1, 'already expired'), - (lambda now: now + 24 * 60 * 60 + 1, 'exceeds 24 hours'), - ], +from langbot.pkg.agent.runner.interaction_store import ( + InteractionAlreadySubmittedError, + InteractionExpiredError, + InteractionNotFoundError, + InteractionScopeError, + InteractionStore, ) -async def test_interaction_expiry_is_bounded(store, event, binding, expires_at, error): - result = _result() - result['data']['payload']['expires_at'] = expires_at(int(time.time())) - manager = InteractionManager(SimpleNamespace(), store=store) - - with pytest.raises(RunnerProtocolError, match=error): - await manager.handle_result( - result_dict=result, - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(FakeAdapter(supports_interactions=True)), - ) +from langbot.pkg.entity.persistence.base import Base -@pytest.mark.asyncio -async def test_interaction_rejects_duplicate_protocol_ids(store, event, binding): - result = _result() - result['data']['payload']['actions'].append({'id': 'approve', 'label': 'Approve again'}) - manager = InteractionManager(SimpleNamespace(), store=store) - - with pytest.raises(RunnerProtocolError, match='duplicate action IDs'): - await manager.handle_result( - result_dict=result, - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(FakeAdapter(supports_interactions=True)), - ) - - -@pytest.mark.asyncio -async def test_interaction_request_payload_is_bounded(store, event, binding): - result = _result() - result['data']['payload']['fields'] = [ - { - 'id': f'field-{field_index}', - 'label': 'Field', - 'type': 'select', - 'options': [ - { - 'value': f'{field_index}-{option_index}', - 'label': 'x' * 512, - } - for option_index in range(100) - ], - } - for field_index in range(10) - ] - manager = InteractionManager(SimpleNamespace(), store=store) - - with pytest.raises(RunnerProtocolError, match='exceeds 256 KiB'): - await manager.handle_result( - result_dict=result, - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(FakeAdapter(supports_interactions=True)), - ) - - -@pytest.mark.asyncio -async def test_non_interaction_action_is_not_consumed(store, event, binding): - manager = InteractionManager(SimpleNamespace(), store=store) - result = _result() - result['data']['action'] = 'platform.message.delete' - - assert not await manager.handle_result( - result_dict=result, - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(FakeAdapter(supports_interactions=True)), - ) - - -async def _deliver_interaction(store, event, binding, result=None): - adapter = FakeAdapter(supports_interactions=True) - manager = InteractionManager(SimpleNamespace(), store=store) - await manager.handle_result( - result_dict=result or _result(), - event=event, - binding=binding, - descriptor=_descriptor(), - run_id='run-1', - adapter_context=_context(adapter), - ) - return manager, adapter.actions[0][1]['callback_token'] - - -@pytest.mark.asyncio -async def test_callback_action_ref_resolves_persisted_action_and_ignores_forged_id(store, event, binding): - manager, callback_token = await _deliver_interaction(store, event, binding) - - resumed = await manager.consume_callback( - callback_token=callback_token, - submission={ - 'interaction_id': 'attacker-selected', - 'action_ref': 1, - 'values': {}, - }, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - assert resumed['submission']['interaction_id'] == 'form-1' - assert resumed['submission']['action_id'] == 'reject' - - -@pytest.mark.asyncio -async def test_callback_submission_time_is_host_owned(store, event, binding): - manager, callback_token = await _deliver_interaction(store, event, binding) - before = int(time.time()) - - resumed = await manager.consume_callback( - callback_token=callback_token, - submission={'action_ref': 0, 'submitted_at': 1}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - assert before <= resumed['submission']['submitted_at'] <= int(time.time()) - assert resumed['submitted_at'] == resumed['submission']['submitted_at'] - - -@pytest.mark.asyncio -async def test_callback_option_refs_resolve_persisted_field_and_value(store, event, binding): - result = _result() - result['data']['payload']['fields'] = [ - { - 'id': 'priority', - 'label': 'Priority', - 'type': 'select', - 'options': [ - {'value': 'normal', 'label': 'Normal'}, - {'value': 'urgent', 'label': 'Urgent'}, - ], - } - ] - manager, callback_token = await _deliver_interaction(store, event, binding, result) - - resumed = await manager.consume_callback( - callback_token=callback_token, - submission={'field_ref': 0, 'option_ref': 1}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - assert resumed['submission']['interaction_id'] == 'form-1' - assert resumed['submission']['values'] == {'priority': 'urgent'} - - -@pytest.mark.asyncio -async def test_callback_rejects_out_of_range_reference(store, event, binding): - manager, callback_token = await _deliver_interaction(store, event, binding) - - with pytest.raises(ValueError, match='action reference is invalid'): - await manager.consume_callback( - callback_token=callback_token, - submission={'action_ref': 99}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - pending = await store.get_request('run-1', 'form-1') - assert pending is not None - assert pending['status'] == 'pending' - - -@pytest.mark.asyncio -async def test_callback_rejects_negative_reference(store, event, binding): - manager, callback_token = await _deliver_interaction(store, event, binding) - - with pytest.raises(ValueError, match='action reference is invalid'): - await manager.consume_callback( - callback_token=callback_token, - submission={'action_ref': -1}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - -@pytest.mark.asyncio -async def test_callback_rejects_action_not_present_in_request(store, event, binding): - manager, callback_token = await _deliver_interaction(store, event, binding) - - with pytest.raises(ValueError, match='action is not present'): - await manager.consume_callback( - callback_token=callback_token, - submission={'action_id': 'attacker-selected'}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - -@pytest.mark.asyncio -async def test_callback_rejects_option_not_present_in_request(store, event, binding): - result = _result() - result['data']['payload']['fields'] = [ - { - 'id': 'priority', - 'label': 'Priority', - 'type': 'select', - 'options': [{'value': 'normal', 'label': 'Normal'}], - } - ] - manager, callback_token = await _deliver_interaction(store, event, binding, result) - - with pytest.raises(ValueError, match='option is not present'): - await manager.consume_callback( - callback_token=callback_token, - submission={'values': {'priority': 'attacker-selected'}}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - -class BlockingAdapter: - def __init__(self): - self.sent = asyncio.Event() - self.token = None - self.updates = [] - - def get_interaction_capabilities(self): - return InteractionDeliveryCapabilities( - action_styles=['default', 'primary', 'danger'], - max_actions=6, - supports_updates=True, - ) - - async def send_interaction(self, target_type, target_id, request, callback_token, reply_to=None): - self.token = callback_token - self.sent.set() - return InteractionHandle(message_id='card-1') - - async def update_interaction(self, handle, state, submission=None): - self.updates.append(state) - return handle - - -class TextAdapter: - def __init__(self): - self.messages = [] - self.sent = asyncio.Event() - - def get_interaction_capabilities(self): - return InteractionDeliveryCapabilities() - - async def send_message(self, target_type, target_id, message): - self.messages.append(str(message)) - self.sent.set() - - -class BlockingStore: - def __init__(self): - self.record = None - self.token = 'test-token' - - async def create_request(self, **data): - self.record = { - **data, - 'status': 'pending', - 'submission': None, - } - return self.record, self.token - - async def record_delivery_success(self, run_id, interaction_id, delivery_result): - self.record['delivery_result'] = delivery_result - - async def get_by_callback_token(self, token): - return self.record if token == self.token else None - - async def consume_submission(self, *, callback_token, submission, bot_id, conversation_id, actor_id, **kwargs): - if self.record['status'] != 'pending': - raise ValueError('Interaction was already submitted') - if self.record['actor_id'] and self.record['actor_id'] != actor_id: - raise ValueError('Actor mismatch') - self.record['status'] = 'submitted' - self.record['submission'] = submission - return self.record - - async def mark_terminal(self, run_id, interaction_id, status): - self.record['status'] = status - return True - - async def mark_delivery_failed(self, run_id, interaction_id, reason): - self.record['status'] = 'delivery_failed' - return True - - -def _blocking_session(): +def _session(): return { - 'run_id': 'blocking-run', - 'runner_id': 'plugin:test/ApprovalRunner/default', + 'run_id': 'run-1', + 'runner_id': 'plugin:test/Runner/default', 'runtime_context': {'deadline_at': time.time() + 300}, 'authorization': { 'binding_id': 'binding-1', @@ -864,224 +45,248 @@ def _blocking_session(): } -@pytest.mark.asyncio -async def test_blocking_request_returns_submission_in_same_run(): - adapter = BlockingAdapter() - store = BlockingStore() - manager = InteractionManager(SimpleNamespace(), store=store) - session = _blocking_session() - original_deadline = session['runtime_context']['deadline_at'] +def _request(**changes): + return { + 'interaction_id': 'approval', + 'title': 'Approve?', + 'actions': [{'id': 'yes', 'label': 'Yes'}, {'id': 'no', 'label': 'No'}], + **changes, + } + + +class Adapter: + def __init__(self, native=True): + self.native = native + self.sent = asyncio.Event() + self.token = None + self.updates = [] + self.messages = [] + + def get_supported_apis(self): + return ['send_interaction', 'update_interaction', 'send_message'] if self.native else ['send_message'] + + def get_interaction_capabilities(self): + return InteractionDeliveryCapabilities( + field_types=['text', 'select'] if self.native else [], + action_styles=['default', 'primary'] if self.native else [], + max_fields=8 if self.native else None, + max_actions=6 if self.native else None, + supports_updates=self.native, + ) + + async def send_interaction(self, target_type, target_id, request, callback_token, reply_to=None): + self.token = callback_token + self.sent.set() + return InteractionHandle(message_id='card-1') + + async def update_interaction(self, handle, state, submission=None): + self.updates.append(state) + return handle + + async def send_message(self, target_type, target_id, message): + self.messages.append(str(message)) + self.sent.set() + + +@pytest.fixture +async def store(tmp_path): + engine = create_async_engine(f'sqlite+aiosqlite:///{tmp_path / "interactions.db"}') + async with engine.begin() as connection: + await connection.run_sync(Base.metadata.create_all) + yield InteractionStore(engine) + await engine.dispose() + + +async def _start(manager, adapter, *, request=None): task = asyncio.create_task( manager.request_interaction( - session=session, + session=_session(), adapter=adapter, - request_payload={ - 'interaction_id': 'approval', - 'title': 'Approve?', - 'actions': [ - {'id': 'yes', 'label': 'Yes'}, - {'id': 'no', 'label': 'No'}, - ], - }, - timeout=1, + request_payload=request or _request(), + timeout=2, on_unsupported='error', responders='actor', ) ) await asyncio.wait_for(adapter.sent.wait(), 1) - record = await manager.consume_callback( - callback_token=adapter.token, - submission={'action_ref': 0}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - assert manager.wake_waiter(record, 'user-1') - result = await task - assert result['status'] == 'submitted' - assert result['submission']['action_id'] == 'yes' - assert adapter.updates == ['submitted'] - assert original_deadline <= result['deadline_at'] < original_deadline + 1 - with pytest.raises(Exception): - await manager.consume_callback( - callback_token=adapter.token, - submission={'action_ref': 1}, + return task + + +def _callback(token, **overrides): + return { + **dict( + callback_token=token, + submission={'action_ref': 0}, bot_id='bot-1', conversation_id='group_chat-1', actor_id='user-1', - ) + ), + **overrides, + } @pytest.mark.asyncio -async def test_blocking_request_times_out_without_polling(): - adapter = BlockingAdapter() - store = BlockingStore() +async def test_callback_is_single_use_and_stays_in_same_run(store): + adapter = Adapter() manager = InteractionManager(SimpleNamespace(), store=store) - result = await manager.request_interaction( - session=_blocking_session(), - adapter=adapter, - request_payload={ - 'interaction_id': 'timeout', - 'title': 'Approve?', - 'actions': [ - {'id': 'yes', 'label': 'Yes'}, - ], - }, - timeout=0.02, - on_unsupported='error', - responders='actor', - ) - assert result['status'] == 'timeout' - assert adapter.updates == ['expired'] - record = await store.get_by_callback_token(adapter.token) - assert record['status'] == 'expired' + task = await _start(manager, adapter) + record = await manager.consume_callback(**_callback(adapter.token, submission={'action_ref': 0, 'action_id': 'no'})) + assert record['run_id'] == 'run-1' + assert manager.wake_waiter(record, 'user-1') + assert (await task)['submission']['action_id'] == 'yes' + assert adapter.updates == ['submitted'] + with pytest.raises(InteractionAlreadySubmittedError): + await manager.consume_callback(**_callback(adapter.token)) @pytest.mark.asyncio -async def test_blocking_request_cancel_invalidates_card(): - adapter = BlockingAdapter() - store = BlockingStore() +async def test_forged_token_actor_and_conversation_do_not_consume(store): + adapter = Adapter() + manager = InteractionManager(SimpleNamespace(), store=store) + task = await _start(manager, adapter) + with pytest.raises(InteractionNotFoundError): + await manager.consume_callback(**_callback('forged')) + for override in ({'actor_id': 'attacker'}, {'conversation_id': 'group-other'}, {'bot_id': 'bot-other'}): + with pytest.raises(InteractionScopeError): + await manager.consume_callback(**_callback(adapter.token, **override)) + assert (await store.get_request('run-1', 'approval'))['status'] == 'pending' + record = await manager.consume_callback(**_callback(adapter.token)) + assert manager.wake_waiter(record, 'user-1') + await task + + +@pytest.mark.asyncio +async def test_responder_list_rejects_other_actor_before_consuming(store): + adapter = Adapter() manager = InteractionManager(SimpleNamespace(), store=store) task = asyncio.create_task( manager.request_interaction( - session=_blocking_session(), + session=_session(), adapter=adapter, - request_payload={ - 'interaction_id': 'cancel', - 'title': 'Approve?', - 'actions': [ - {'id': 'yes', 'label': 'Yes'}, - ], - }, - timeout=1, - on_unsupported='error', - responders='actor', - ) - ) - await asyncio.wait_for(adapter.sent.wait(), 1) - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - assert store.record['status'] == 'cancelled' - assert adapter.updates == ['cancelled'] - assert not manager._waiters - - -@pytest.mark.asyncio -async def test_blocking_request_rejects_unlisted_responder_before_consuming(): - adapter = BlockingAdapter() - store = BlockingStore() - manager = InteractionManager(SimpleNamespace(), store=store) - task = asyncio.create_task( - manager.request_interaction( - session=_blocking_session(), - adapter=adapter, - request_payload={ - 'interaction_id': 'restricted', - 'title': 'Approve?', - 'actions': [ - {'id': 'yes', 'label': 'Yes'}, - ], - }, - timeout=1, + request_payload=_request(), + timeout=2, on_unsupported='error', responders=['user-1'], ) ) await asyncio.wait_for(adapter.sent.wait(), 1) with pytest.raises(ValueError, match='allowed responder'): - await manager.consume_callback( - callback_token=adapter.token, - submission={'action_ref': 0}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-2', - ) - assert store.record['status'] == 'pending' - record = await manager.consume_callback( - callback_token=adapter.token, - submission={'action_ref': 0}, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) + await manager.consume_callback(**_callback(adapter.token, actor_id='attacker')) + assert (await store.get_request('run-1', 'approval'))['status'] == 'pending' + record = await manager.consume_callback(**_callback(adapter.token)) assert manager.wake_waiter(record, 'user-1') - assert (await task)['status'] == 'submitted' + await task @pytest.mark.asyncio -async def test_text_fallback_consumes_numbered_action(): - adapter = TextAdapter() - store = BlockingStore() +async def test_restart_cancels_pending_and_old_callback_expires(store): + adapter = Adapter() manager = InteractionManager(SimpleNamespace(), store=store) - task = asyncio.create_task( - manager.request_interaction( - session=_blocking_session(), - adapter=adapter, - request_payload={ - 'interaction_id': 'text-choice', - 'title': 'Approve?', - 'actions': [ - {'id': 'yes', 'label': 'Yes'}, - {'id': 'no', 'label': 'No'}, - ], - }, - timeout=1, - on_unsupported='degrade', - responders='actor', + task = await _start(manager, adapter) + assert await store.cancel_orphaned_pending(set()) == 1 + assert (await store.get_request('run-1', 'approval'))['status'] == 'cancelled' + with pytest.raises(InteractionExpiredError): + await manager.consume_callback(**_callback(adapter.token)) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.asyncio +async def test_timeout_updates_card(store): + adapter = Adapter() + manager = InteractionManager(SimpleNamespace(), store=store) + result = await manager.request_interaction( + session=_session(), + adapter=adapter, + request_payload=_request(), + timeout=0.01, + on_unsupported='error', + responders='actor', + ) + assert result['status'] == 'timeout' + assert adapter.updates == ['expired'] + + +@pytest.mark.asyncio +@pytest.mark.parametrize('policy', ['degrade', 'error']) +async def test_multi_field_form_never_splits_into_text_questions(store, policy): + adapter = Adapter(native=False) + manager = InteractionManager(SimpleNamespace(), store=store) + result = await manager.request_interaction( + session=_session(), + adapter=adapter, + request_payload=_request( + actions=[], + fields=[ + {'id': 'first', 'label': 'First', 'type': 'text'}, + {'id': 'second', 'label': 'Second', 'type': 'text'}, + ], + ), + timeout=1, + on_unsupported=policy, + responders='actor', + ) + assert result['unsupported'] + assert adapter.messages == [] + + +@pytest.mark.asyncio +async def test_visual_degradation_keeps_native_form(store): + adapter = Adapter() + manager = InteractionManager(SimpleNamespace(), store=store) + task = await _start( + manager, + adapter, + request=_request( + actions=[], + fields=[ + {'id': 'name', 'label': 'Name', 'type': 'text', 'placeholder': 'Your name', 'default': 'Alex'}, + ], + ), + ) + record = await store.get_request('run-1', 'approval') + assert record['request']['fields'][0]['placeholder'] is None + assert record['request']['fields'][0]['default'] is None + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.mark.parametrize('policy', ['error', 'degrade']) +def test_structural_policy_and_visual_degradation_are_independent(policy): + capabilities = InteractionDeliveryCapabilities( + field_types=['text'], + action_styles=['default'], + max_fields=8, + max_actions=2, + text_fallback=True, + supports_markdown=False, + ) + structural = InteractionRequest.model_validate( + _request( + actions=[], + fields=[{'id': 'count', 'label': 'Count', 'type': 'number'}], ) ) - await asyncio.wait_for(adapter.sent.wait(), 1) - assert '1 Yes / 2 No' in adapter.messages[0] - assert await manager.consume_text_message( - workspace_id='workspace-1', - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - text='2', + _, _, use_text, issues = InteractionManager._normalize_request(structural, capabilities, policy) + if policy == 'error': + assert use_text is False + assert issues == [{'path': 'fields[0].type', 'reason': 'unsupported_field_type'}] + else: + assert use_text is True + assert issues == [] + + visual = InteractionRequest.model_validate( + _request( + description='**Approve**', + actions=[{'id': 'yes', 'label': 'Yes', 'style': 'primary'}], + ) ) - result = await task - assert result['submission']['action_id'] == 'no' - assert result['degraded'][-1]['applied'] == 'text_fallback' - - -@pytest.mark.asyncio -async def test_unsupported_error_does_not_persist_request(): - store = BlockingStore() - manager = InteractionManager(SimpleNamespace(), store=store) - result = await manager.request_interaction( - session=_blocking_session(), - adapter=TextAdapter(), - request_payload={'interaction_id': 'error', 'title': 'Approve?', 'actions': [{'id': 'yes', 'label': 'Yes'}]}, - timeout=1, - on_unsupported='error', - responders='actor', - ) - assert result['unsupported'][0]['path'] == 'platform' - assert store.record is None - - -@pytest.mark.asyncio -async def test_advertised_card_shape_without_send_api_is_unsupported(): - class UnavailableCardAdapter(BlockingAdapter): - def get_supported_apis(self): - return ['send_message'] - - store = BlockingStore() - manager = InteractionManager(SimpleNamespace(), store=store) - result = await manager.request_interaction( - session=_blocking_session(), - adapter=UnavailableCardAdapter(), - request_payload={ - 'interaction_id': 'unavailable', - 'title': 'Approve?', - 'actions': [ - {'id': 'yes', 'label': 'Yes'}, - ], - }, - timeout=1, - on_unsupported='error', - responders='actor', - ) - assert result['unsupported'][0]['reason'] == 'native_interactions_unavailable' - assert store.record is None + normalized, degraded, use_text, issues = InteractionManager._normalize_request(visual, capabilities, policy) + assert not use_text and not issues + assert normalized.actions[0].style == 'default' + assert {(item['path'], item['reason']) for item in degraded} == { + ('actions[0].style', 'unsupported_style'), + ('description', 'unsupported_markdown'), + } diff --git a/tests/unit_tests/agent/test_legacy_identity_boundary.py b/tests/unit_tests/agent/test_legacy_identity_boundary.py index d5347acfb..e453a8afb 100644 --- a/tests/unit_tests/agent/test_legacy_identity_boundary.py +++ b/tests/unit_tests/agent/test_legacy_identity_boundary.py @@ -13,10 +13,6 @@ from langbot_plugin.entities.io.context import InstallationBinding from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter from langbot.pkg.agent.runner.context_builder import RunnerContextBuilder from langbot.pkg.agent.runner.host_models import AgentEventEnvelope -from langbot.pkg.agent.runner.interaction_manager import InteractionManager -from langbot.pkg.agent.runner.interaction_store import InteractionStore -from langbot.pkg.entity.persistence.base import Base -from sqlalchemy.ext.asyncio import create_async_engine from tests.unit_tests.agent import test_event_first_protocol as event_fixtures from tests.unit_tests.agent.test_context_validation import TestContextValidation as Helpers from tests.utils.legacy_plugin_contract import load_identity_helper, verified_plugin_root @@ -102,100 +98,6 @@ async def test_provenance_cannot_cross_pipeline(mock_query): @pytest.mark.asyncio -@pytest.mark.parametrize('changed', [None, 'workspace_id', 'bot_id', 'processor_id', 'actor_id']) -async def test_resume_uses_persisted_identity_not_new_session(tmp_path, mock_query, changed): - engine = create_async_engine('sqlite+aiosqlite:///:memory:') - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - try: - store = InteractionStore(engine) - event = QueryEntryAdapter.query_to_event(mock_query) - context, binding = await build(event, 'DifyAgent') - manager = InteractionManager(SimpleNamespace(), store) - descriptor = Helpers()._make_descriptor() - descriptor.id = binding.runner_id - descriptor.capabilities.interactions = True - descriptor.permissions.interactions = ['request'] - binding.delivery_policy.enable_interactions = True - import sqlalchemy as sa - from langbot.pkg.entity.persistence.pipeline import LegacyPipeline - - config = { - 'ai': {'runner': {'id': binding.runner_id}, 'runner_config': {binding.runner_id: binding.runner_config}} - } - mock_query.pipeline_config = config - conversation = mock_query.session.using_conversation - async with engine.begin() as conn: - await conn.execute( - sa.insert(LegacyPipeline).values( - uuid=binding.processor_id, - workspace_uuid=event.workspace_id, - name='test', - description='', - for_version='4.11', - stages=[], - config=config, - extensions_preferences={}, - ) - ) - adapter = SimpleNamespace( - get_supported_apis=lambda: ['interaction.request'], call_platform_api=AsyncMock(return_value={}) - ) - mock_query.adapter = adapter - await manager.handle_result( - result_dict={ - 'data': { - 'action': 'interaction.requested', - 'payload': {'interaction_id': 'form', 'title': 'Approve', 'fallback_text': 'Approve'}, - } - }, - event=event, - binding=binding, - descriptor=descriptor, - run_id='original', - adapter_context={ - '_delivery_adapter': adapter, - '_query': mock_query, - '_pipeline_expected_config': config, - '_pipeline_conversation': conversation, - }, - ) - token = adapter.call_platform_api.call_args.args[1]['callback_token'] - record = await manager.consume_callback( - callback_token=token, - submission={'interaction_id': 'form', 'values': {}}, - bot_id=event.bot_id, - conversation_id='person_launcher-123', - actor_id='sender-123', - ) - mock_query.variables['_interaction_submission'] = record['submission'] - mock_query.session.launcher_id = 'wrong-new-session' - resumed = QueryEntryAdapter.query_to_event(mock_query) - if changed in ('workspace_id', 'bot_id'): - setattr(resumed, changed, 'other') - elif changed == 'processor_id': - binding.processor_id = 'other' - elif changed == 'actor_id': - resumed.actor.actor_id = 'other' - # A fresh manager/store represents a process restart, not an in-memory cache. - manager = InteractionManager(SimpleNamespace(), InteractionStore(engine)) - await manager.restore_legacy_identity(resumed, binding) - result, _ = await build(resumed, 'DifyAgent') - assert result['conversation']['launcher_id'] == (None if changed else 'launcher-123') - finally: - await engine.dispose() - - -@pytest.mark.asyncio -async def test_tbox_resume_without_persisted_identity_fails_closed(mock_query): - event = QueryEntryAdapter.query_to_event(mock_query) - event.event_type = 'interaction.submitted' - event.legacy_identity = None - context, _ = await build(event, 'TboxAgent', 'legacy-bot') - assert context['conversation']['bot_id'] is None - assert context['runtime']['metadata']['bot_id'] is None - - @pytest.fixture(scope='session') def official_plugin_root(): return verified_plugin_root() diff --git a/tests/unit_tests/agent/test_orchestrator_integration.py b/tests/unit_tests/agent/test_orchestrator_integration.py index bf1ba5bea..9106ec3f9 100644 --- a/tests/unit_tests/agent/test_orchestrator_integration.py +++ b/tests/unit_tests/agent/test_orchestrator_integration.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio import datetime import types -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest from sqlalchemy.ext.asyncio import create_async_engine, AsyncEngine @@ -17,7 +17,6 @@ from langbot.pkg.agent.runner.query_entry_adapter import QueryEntryAdapter from langbot.pkg.agent.runner.binding_resolver import AgentBindingResolver from langbot.pkg.agent.runner.session_registry import get_session_registry from langbot.pkg.agent.runner.run_ledger_store import RunLedgerStore -from langbot.pkg.agent.runner.interaction_store import InteractionStore from langbot.pkg.agent.runner.persistent_state_store import reset_persistent_state_store from langbot.pkg.api.http.context import ExecutionContext from langbot_plugin.api.entities.builtin.platform import entities as platform_entities @@ -432,84 +431,6 @@ async def test_orchestrator_runs_fake_plugin_with_authorized_context(clean_agent assert await get_session_registry().get(context['run_id']) is None -@pytest.mark.asyncio -async def test_orchestrator_consumes_interaction_request_before_message_output(clean_agent_state): - """Whitelisted interaction actions are delivered and never emitted as messages.""" - db_engine = clean_agent_state - descriptor = make_descriptor() - # The local SDK pin predates this contract; these become typed fields with SDK 0.5.0a3. - descriptor.capabilities.__dict__['interactions'] = True - descriptor.permissions.__dict__['interactions'] = ['request'] - plugin_connector = FakePluginConnector( - results=[ - { - 'type': 'action.requested', - 'data': { - 'action': 'interaction.requested', - 'payload': { - 'interaction_id': 'approval-1', - 'kind': 'choice', - 'title': 'Approve request?', - 'actions': [ - {'id': 'approve', 'label': 'Approve', 'style': 'primary'}, - {'id': 'reject', 'label': 'Reject', 'style': 'danger'}, - ], - 'fallback_text': 'Reply approve or reject.', - }, - }, - }, - { - 'type': 'message.completed', - 'data': {'message': {'role': 'assistant', 'content': 'Waiting for approval'}}, - }, - ] - ) - ap = FakeApplication(plugin_connector, db_engine) - orchestrator = AgentRunOrchestrator(ap, FakeRegistry(descriptor)) - query = make_query() - query.adapter = types.SimpleNamespace( - get_supported_apis=lambda: ['interaction.request'], - call_platform_api=AsyncMock(return_value={'ok': True}), - ) - - import sqlalchemy as sa - from langbot.pkg.entity.persistence.pipeline import LegacyPipeline - - async with db_engine.begin() as conn: - await conn.execute( - sa.insert(LegacyPipeline).values( - uuid=query.pipeline_uuid, - workspace_uuid=TEST_CONTEXT.workspace_uuid, - name='test', - description='', - for_version='4.11', - stages=[], - config=query.pipeline_config, - extensions_preferences={}, - ) - ) - - messages = [message async for message in orchestrator.run_from_query(query)] - - assert [message.content for message in messages] == ['Waiting for approval'] - query.adapter.call_platform_api.assert_awaited_once() - action, params = query.adapter.call_platform_api.await_args.args - assert action == 'interaction.request' - assert params['reply_target'] == { - 'target_type': 'person', - 'target_id': 'user_001', - 'message_id': 'msg_001', - } - assert params['callback_token'] - - run_id = plugin_connector.contexts[0]['run_id'] - request = await InteractionStore(db_engine).get_request(run_id, 'approval-1') - assert request is not None - assert request['status'] == 'pending' - assert request['processor_type'] == 'pipeline' - assert request['processor_id'] == 'pipeline_001' - - @pytest.mark.asyncio async def test_orchestrator_persists_run_ledger(clean_agent_state): """AgentRunOrchestrator records Host-owned run and result events.""" @@ -1020,6 +941,70 @@ async def test_unconsumed_steering_audit_does_not_persist_pinned_context(clean_a assert pinned_context not in str(event_logs) +@pytest.mark.asyncio +@pytest.mark.parametrize( + ('waiting', 'supports_steering', 'target_run_id', 'expected'), + [ + ({'run-1'}, True, 'run-1', True), + (set(), True, 'run-1', False), + ({'run-1'}, False, 'run-1', False), + ({'run-1'}, True, None, False), + ], +) +async def test_platform_event_steering_requires_waiting_run_and_matching_binding( + waiting, + supports_steering, + target_run_id, + expected, +): + event = types.SimpleNamespace( + event_type='message.received', + event_id='event-1', + conversation_id='person-user-1', + bot_id='bot-1', + workspace_id='workspace-test', + thread_id=None, + ) + binding = types.SimpleNamespace(runner_id=RUNNER_ID, binding_id='binding-1') + descriptor = types.SimpleNamespace(id=RUNNER_ID, supports_steering=lambda: supports_steering) + sessions = types.SimpleNamespace( + find_steering_target=AsyncMock(return_value=target_run_id), + enqueue_steering=AsyncMock(return_value=True), + ) + journal = types.SimpleNamespace(write_event_log=AsyncMock(return_value='log-1'), write_user_transcript=AsyncMock()) + orchestrator = types.SimpleNamespace( + interaction_manager=types.SimpleNamespace(waiting_run_ids=lambda: waiting), + registry=types.SimpleNamespace(get=AsyncMock(return_value=descriptor)), + _session_registry=sessions, + _build_steering_item=Mock(return_value={'claimed_at': 1}), + journal=journal, + ap=types.SimpleNamespace(logger=FakeLogger()), + ) + + claimed = await AgentRunOrchestrator.try_claim_steering_from_event( + orchestrator, + event, + binding, + TEST_CONTEXT, + ) + + assert claimed is expected + if waiting and supports_steering: + sessions.find_steering_target.assert_awaited_once_with( + conversation_id='person-user-1', + runner_id=RUNNER_ID, + bot_id='bot-1', + workspace_id='workspace-test', + thread_id=None, + binding_id='binding-1', + eligible_run_ids=waiting, + ) + else: + sessions.find_steering_target.assert_not_awaited() + assert sessions.enqueue_steering.await_count == int(expected) + assert journal.write_event_log.await_count == int(expected) + + @pytest.mark.asyncio async def test_orchestrator_enforces_total_runner_deadline(clean_agent_state): """Test that orchestrator enforces total runner timeout.""" @@ -1270,7 +1255,7 @@ class TestQueryEntrySessionQueryId: @pytest.mark.asyncio -@pytest.mark.parametrize('event_type', ['message.received', 'interaction.submitted']) +@pytest.mark.parametrize('event_type', ['message.received']) @pytest.mark.parametrize('completed_message', [False, True]) @pytest.mark.parametrize('reasoning', [False, True]) async def test_direct_agent_streams_output_to_the_originating_platform( diff --git a/tests/unit_tests/agent/test_platform_tools.py b/tests/unit_tests/agent/test_platform_tools.py index 3a9fbfa47..0882414ac 100644 --- a/tests/unit_tests/agent/test_platform_tools.py +++ b/tests/unit_tests/agent/test_platform_tools.py @@ -209,8 +209,8 @@ def test_platform_resources_require_runner_call_permission() -> None: assert capabilities['unavailable_tools'] == [{'name': 'event_reply', 'reason': 'runner_call_permission_missing'}] -def test_interaction_resume_can_reply_only_to_a_frozen_delivery_target() -> None: - event = _event().model_copy(update={'event_type': 'interaction.submitted'}) +def test_message_can_reply_only_to_a_frozen_delivery_target() -> None: + event = _event() names = resolve_agent_platform_tool_names({}, event.event_type) assert 'event_reply' in names resources, _ = build_platform_tool_resources(event, names, ['detail', 'call']) @@ -396,7 +396,7 @@ async def test_event_reply_uses_run_reply_stream_when_available() -> None: @pytest.mark.asyncio async def test_request_interaction_uses_host_interaction_manager() -> None: - adapter = SimpleNamespace(get_supported_apis=lambda: ['interaction.request']) + adapter = SimpleNamespace(get_supported_apis=lambda: ['send_interaction']) manager = SimpleNamespace( request_interaction=AsyncMock( return_value={'status': 'submitted', 'submission': {'interaction_id': 'card-host-generated'}} @@ -407,7 +407,7 @@ async def test_request_interaction_uses_host_interaction_manager() -> None: platform_mgr=SimpleNamespace(get_bot_by_uuid=AsyncMock(return_value=SimpleNamespace(adapter=adapter))), ) event = _event('message.received') - event.delivery.platform_capabilities['supported_apis'].append('interaction.request') + event.delivery.platform_capabilities['supported_apis'].append('send_interaction') session = { 'run_id': 'run-1', 'runner_id': 'plugin:test/Agent/default', @@ -477,7 +477,7 @@ def test_interactive_card_tool_is_automatic_only_for_interactive_message_surface def test_interactive_card_tool_schema_is_narrowed_to_adapter_capabilities() -> None: event = _event('message.received') - event.delivery.platform_capabilities['supported_apis'].append('interaction.request') + event.delivery.platform_capabilities['supported_apis'].append('send_interaction') event.delivery.interactions = InteractionDeliveryCapabilities( field_types=['select'], action_styles=['default', 'primary'], diff --git a/tests/unit_tests/agent/test_reply_stream.py b/tests/unit_tests/agent/test_reply_stream.py index 184a412d8..922a361a7 100644 --- a/tests/unit_tests/agent/test_reply_stream.py +++ b/tests/unit_tests/agent/test_reply_stream.py @@ -145,9 +145,9 @@ async def test_target_streaming_adapter_updates_card_without_source_message(): await session.close() -async def test_interaction_callback_buffers_one_final_message_after_card_update(): +async def test_non_message_event_buffers_one_final_reply(): event = SimpleNamespace( - event_type='interaction.submitted', + event_type='platform.specific', delivery=SimpleNamespace( surface='platform', platform_capabilities={}, @@ -157,7 +157,7 @@ async def test_interaction_callback_buffers_one_final_message_after_card_update( source = events.PlatformSpecificEvent( type='platform.specific', adapter_name='wecombot-omni', - action='interaction.submitted', + action='test.event', data={'callback_req_id': 'callback-1'}, ) adapter = SimpleNamespace( @@ -175,9 +175,9 @@ async def test_interaction_callback_buffers_one_final_message_after_card_update( await session.close() -async def test_interaction_reply_is_sent_after_card_as_new_message(): +async def test_target_reply_is_sent_as_new_message(): event = SimpleNamespace( - event_type='interaction.submitted', + event_type='message.received', delivery=SimpleNamespace( surface='platform', platform_capabilities={}, @@ -199,43 +199,6 @@ async def test_interaction_reply_is_sent_after_card_as_new_message(): await session.close() -@pytest.mark.parametrize('stream_fails', [False, True]) -async def test_interaction_uses_new_stream_with_buffered_fallback(stream_fails): - event = SimpleNamespace( - event_type='interaction.submitted', - delivery=SimpleNamespace( - surface='platform', - platform_capabilities={}, - reply_target={'target_type': 'person', 'target_id': 'user-1', 'message_id': 'original-1'}, - ), - ) - adapter = SimpleNamespace( - is_stream_output_supported=AsyncMock(return_value=True), - begin_interaction_stream=AsyncMock(return_value=True), - reply_interaction_stream_chunk=AsyncMock(side_effect=RuntimeError('invalid req_id') if stream_fails else None), - send_message=AsyncMock(), - ) - session = ReplyStreamSession(event, adapter) - key = uuid4() - await session.apply(request(key, text='part')) - result = await session.apply(request(key, 'finish', 'part done')) - - adapter.begin_interaction_stream.assert_awaited_once_with('original-1', event.delivery.reply_target) - if stream_fails: - assert result['delivery'] == 'buffered' - adapter.send_message.assert_awaited_once() - assert adapter.send_message.await_args.args[2][0].text == 'part done' - else: - assert result['delivery'] == 'streaming' - assert [call.kwargs['content'] for call in adapter.reply_interaction_stream_chunk.await_args_list] == [ - 'part', - 'part done', - ] - assert adapter.reply_interaction_stream_chunk.await_args.kwargs['is_final'] is True - adapter.send_message.assert_not_awaited() - await session.close() - - @pytest.mark.parametrize('native', [True, False]) @pytest.mark.parametrize('error', [RuntimeError, asyncio.CancelledError]) async def test_exception_finalizes_visible_card_without_sending_buffered_partial(native, error): diff --git a/tests/unit_tests/agent/test_resource_builder.py b/tests/unit_tests/agent/test_resource_builder.py index ae8c56971..47de83d6e 100644 --- a/tests/unit_tests/agent/test_resource_builder.py +++ b/tests/unit_tests/agent/test_resource_builder.py @@ -250,7 +250,7 @@ async def test_platform_tools_are_not_claimed_when_runner_disables_tool_calling( async def test_pipeline_receives_interactive_card_tool_for_supported_adapter(app): query = make_query({}) query.adapter = SimpleNamespace( - get_supported_apis=lambda: ['interaction.request'], + get_supported_apis=lambda: ['send_interaction'], get_interaction_capabilities=lambda: { 'field_types': ['select'], 'action_styles': ['default', 'primary', 'danger'], @@ -283,7 +283,7 @@ async def test_event_processor_cannot_receive_interactive_card_tool(app): delivery=DeliveryContext( surface='platform', reply_target={'target_type': 'person', 'target_id': 'user-1'}, - platform_capabilities={'supported_apis': ['interaction.request']}, + platform_capabilities={'supported_apis': ['send_interaction']}, ), ) binding = AgentBinding( diff --git a/tests/unit_tests/pipeline/test_aggregator.py b/tests/unit_tests/pipeline/test_aggregator.py index 274c6b523..f9bc74631 100644 --- a/tests/unit_tests/pipeline/test_aggregator.py +++ b/tests/unit_tests/pipeline/test_aggregator.py @@ -560,14 +560,12 @@ class TestMessageAggregatorAddMessage: message_chain=chain, adapter=mock_adapter(), pipeline_uuid='test-pipeline', - variables={'_interaction_submission': {'interaction_id': 'form-1'}}, + variables={'test_marker': {'interaction_id': 'form-1'}}, ) assert agg.buffers == {} app.query_pool.add_query.assert_awaited_once() - assert app.query_pool.add_query.call_args.kwargs['variables'] == { - '_interaction_submission': {'interaction_id': 'form-1'} - } + assert app.query_pool.add_query.call_args.kwargs['variables'] == {'test_marker': {'interaction_id': 'form-1'}} @pytest.mark.asyncio async def test_max_buffer_flushes_immediately(self): diff --git a/tests/unit_tests/pipeline/test_pool.py b/tests/unit_tests/pipeline/test_pool.py index ffd237606..364664156 100644 --- a/tests/unit_tests/pipeline/test_pool.py +++ b/tests/unit_tests/pipeline/test_pool.py @@ -258,12 +258,12 @@ class TestQueryPoolAddQuery: message_chain=Mock(), adapter=Mock(), routed_by_rule=True, - variables={'_interaction_submission': {'interaction_id': 'form-1'}}, + variables={'test_marker': {'interaction_id': 'form-1'}}, ) variables = MockQuery.call_args.kwargs['variables'] assert variables['_routed_by_rule'] is True - assert variables['_interaction_submission'] == {'interaction_id': 'form-1'} + assert variables['test_marker'] == {'interaction_id': 'form-1'} async def test_add_query_notifier_condition(self): """add_query notifies waiting consumers.""" diff --git a/tests/unit_tests/pipeline/test_resprule.py b/tests/unit_tests/pipeline/test_resprule.py index b66814e31..8d7593c27 100644 --- a/tests/unit_tests/pipeline/test_resprule.py +++ b/tests/unit_tests/pipeline/test_resprule.py @@ -251,13 +251,12 @@ async def test_group_response_rule_configuration( @pytest.mark.asyncio -@pytest.mark.parametrize('callback_key', ['_interaction_submission', '_dify_form_action']) -async def test_interaction_callbacks_do_not_require_another_mention(mock_app, sample_query, callback_key): +async def test_dify_form_action_does_not_require_another_mention(mock_app, sample_query): resprule, entities, _, _ = get_modules() sample_query.launcher_type = provider_session.LauncherTypes.GROUP sample_query.adapter.bot_account_id = '999' submission = {'action_id': 'approve'} - sample_query.variables = {'_routed_by_rule': True, callback_key: submission} + sample_query.variables = {'_routed_by_rule': True, '_dify_form_action': submission} sample_query.message_chain = platform_message.MessageChain([platform_message.Plain(text='[Form Action: Approve]')]) sample_query.pipeline_config = { 'trigger': {'group-respond-rules': {'at': True, 'prefix': ['!bot '], 'regexp': [], 'random': 0}} @@ -269,7 +268,7 @@ async def test_interaction_callbacks_do_not_require_another_mention(mock_app, sa assert result.result_type == entities.ResultType.CONTINUE assert str(result.new_query.message_chain) == '[Form Action: Approve]' - assert result.new_query.variables[callback_key] == submission + assert result.new_query.variables['_dify_form_action'] == submission @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_botmgr_execution_trace.py b/tests/unit_tests/platform/test_botmgr_execution_trace.py index f3a523b0d..8c1693361 100644 --- a/tests/unit_tests/platform/test_botmgr_execution_trace.py +++ b/tests/unit_tests/platform/test_botmgr_execution_trace.py @@ -376,29 +376,16 @@ async def test_matched_subscription_keeps_failed_timeout_and_cancelled_execution await counters.shutdown() -@pytest.mark.parametrize('processor', ['agent', 'pipeline', 'invalid', 'expired', 'duplicate']) -async def test_interaction_admission_requires_valid_callback(processor): - from langbot_plugin.api.entities.builtin.platform import events +async def test_interaction_callback_failure_does_not_start_a_traced_run(): + from langbot_plugin.api.entities.builtin.platform.events import InteractionCallbackEvent bot, manager, counters = make_bot([]) - callbacks = SimpleNamespace( - consume_callback=AsyncMock(return_value={'processor_type': processor, 'processor_id': 'target'}), - acknowledge_submission=AsyncMock(), - ) - if processor in {'expired', 'duplicate'}: - callbacks.consume_callback.side_effect = ValueError(processor) + callbacks = SimpleNamespace(consume_callback=AsyncMock(side_effect=RuntimeError('store unavailable'))) bot.ap.agent_run_orchestrator = SimpleNamespace(interaction_manager=callbacks) - bot._resume_agent_interaction = AsyncMock(side_effect=ValueError('target unavailable')) - event = events.PlatformSpecificEvent(platform='test', action='interaction.submitted', data={}) + event = InteractionCallbackEvent(callback_token='token-1', actor_id='user-1') await bot._handle_platform_event(event, FakeAdapter()) - if processor in {'agent', 'pipeline'}: - (chain,) = execution_chains(await flushed_records(counters, manager)) - assert chain['outcome'] == 'failed' - callbacks.acknowledge_submission.assert_awaited_once() - await counters.shutdown() - else: - callbacks.acknowledge_submission.assert_not_awaited() - await assert_no_upload(counters, manager) + callbacks.consume_callback.assert_awaited_once() + await assert_no_upload(counters, manager) async def test_rejected_event_does_not_contaminate_next_matched_event_or_monitoring(): @@ -537,32 +524,17 @@ async def test_retry_of_matched_batch_cannot_include_intervening_unrouted_event( assert counters.dropped == 0 -async def test_valid_interaction_success_keeps_acknowledgement_and_execution(): - from langbot.pkg.telemetry.execution import record - from langbot_plugin.api.entities.builtin.platform import events +async def test_valid_interaction_callback_wakes_waiter_without_new_execution(): + from langbot_plugin.api.entities.builtin.platform.events import InteractionCallbackEvent bot, manager, counters = make_bot([]) - - async def acknowledge(*args): - record(bot.ap, TEST_CONTEXT, family='platform_api', operation='ack', outcome='success') - - async def resume(*args): - record(bot.ap, TEST_CONTEXT, family='runner', operation='execute', outcome='success') - - bot.ap.agent_run_orchestrator = SimpleNamespace( - interaction_manager=SimpleNamespace( - consume_callback=AsyncMock(return_value={'processor_type': 'agent'}), - acknowledge_submission=acknowledge, - ) - ) - bot._resume_agent_interaction = resume - event = events.PlatformSpecificEvent(platform='test', action='interaction.submitted', data={}) + record = {'run_id': 'run-1', 'submission': {'action_id': 'approve'}} + callbacks = SimpleNamespace(consume_callback=AsyncMock(return_value=record), wake_waiter=Mock(return_value=True)) + bot.ap.agent_run_orchestrator = SimpleNamespace(interaction_manager=callbacks) + event = InteractionCallbackEvent(callback_token='token-1', actor_id='user-1') await bot._handle_platform_event(event, FakeAdapter()) - records = await flushed_records(counters, manager) - (chain,) = execution_chains(records) - assert chain['outcome'] == 'success' - assert {node['family'] for node in execution_nodes(records)} == {'platform_event', 'platform_api', 'runner'} - await counters.shutdown() + callbacks.wake_waiter.assert_called_once_with(record, 'user-1', {}) + await assert_no_upload(counters, manager) @pytest.mark.parametrize('channel_id', ['channel-1', 'channel-2']) diff --git a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py index cf0eebfc0..ea867d4b0 100644 --- a/tests/unit_tests/platform/test_dingtalk_eba_adapter.py +++ b/tests/unit_tests/platform/test_dingtalk_eba_adapter.py @@ -20,6 +20,48 @@ from langbot_plugin.api.definition.abstract.platform.event_logger import Abstrac from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) + + +async def _send_interaction(adapter, params): + target = params['reply_target'] + request = InteractionRequest.model_validate( + { + 'interaction_id': 'test-interaction', + 'title': 'Interaction', + **params['request'], + } + ) + handle = await adapter.send_interaction( + target['target_type'], + target['target_id'], + request, + params['callback_token'], + reply_to=target.get('message_id'), + ) + return handle.model_dump() + + +async def _update_interaction(adapter, params): + target = params['update_target'] + handle = InteractionHandle.model_validate( + { + **target, + 'context': {'request': params['request'], 'callback_context': params.get('callback_context') or {}}, + } + ) + submission = InteractionSubmission.model_validate( + { + 'interaction_id': 'test-interaction', + **params['submission'], + } + ) + updated = await adapter.update_interaction(handle, params.get('state', 'submitted'), submission) + return updated.model_dump() class DummyLogger(AbstractEventLogger): @@ -336,8 +378,8 @@ async def test_dingtalk_target_reply_stream_updates_one_card(): @pytest.mark.asyncio async def test_dingtalk_interaction_delivery_and_callback_event(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.request', + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, @@ -365,17 +407,17 @@ async def test_dingtalk_interaction_delivery_and_callback_event(): space_id='dtv1.card//IM_GROUP.group-1', ) event = interaction_event_from_native(native) - assert event.action == 'interaction.submitted' - assert event.data['action_ref'] == 0 - assert event.data['actor_id'] == 'user-1' - assert event.data['target_id'] == 'group-1' + assert isinstance(event, platform_events.InteractionCallbackEvent) + assert event.raw_submission['action_ref'] == 0 + assert event.actor_id == 'user-1' + assert event.target_id == 'group-1' @pytest.mark.asyncio async def test_dingtalk_native_input_uses_template_variables_and_submits_value(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.request', + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, @@ -428,16 +470,16 @@ async def test_dingtalk_native_input_uses_template_variables_and_submits_value() ) event = interaction_event_from_native(native, adapter.interaction_callback_contexts) - assert event.data['callback_token'] == 'callback-token' - assert event.data['values'] == {'comment': 'Looks good'} + assert event.callback_token == 'callback-token' + assert event.values == {'comment': 'Looks good'} assert result['message_id'] in adapter.interaction_callback_contexts @pytest.mark.asyncio async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.request', + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, @@ -483,10 +525,10 @@ async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback( ) event = interaction_event_from_native(native, adapter.interaction_callback_contexts) - assert event.data['target_type'] == 'person' - assert event.data['target_id'] == 'user-1' - assert event.data['values'] == {'priority': 'urgent'} - assert 'field_ref' not in event.data + assert event.target_type == 'person' + assert event.target_id == 'user-1' + assert event.values == {'priority': 'urgent'} + assert 'field_ref' not in event.raw_submission resolved = InteractionManager._resolve_submission_refs( { 'interaction_id': 'form-1', @@ -496,7 +538,7 @@ async def test_dingtalk_native_select_uses_select_block_and_normalizes_callback( ] }, }, - {'values': event.data['values']}, + {'values': event.values}, ) assert resolved['values'] == {'priority': 'urgent'} @@ -509,9 +551,9 @@ async def test_dingtalk_stream_callback_submits_dropdown_without_space_id(): async def listener(event, _adapter): received.append(event) - adapter.register_listener(platform_events.PlatformSpecificEvent, listener) - result = await adapter.call_platform_api( - 'interaction.request', + adapter.register_listener(platform_events.InteractionCallbackEvent, listener) + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, @@ -520,6 +562,7 @@ async def test_dingtalk_stream_callback_submits_dropdown_without_space_id(): 'fields': [ { 'id': 'priority', + 'label': 'Priority', 'type': 'select', 'options': [{'value': 'urgent', 'label': 'Urgent'}], } @@ -551,10 +594,10 @@ async def test_dingtalk_stream_callback_submits_dropdown_without_space_id(): ) assert len(received) == 1 - assert received[0].action == 'interaction.submitted' - assert received[0].data['target_type'] == 'person' - assert received[0].data['target_id'] == 'user-1' - assert received[0].data['values'] == {'priority': 'urgent'} + assert isinstance(received[0], platform_events.InteractionCallbackEvent) + assert received[0].target_type == 'person' + assert received[0].target_id == 'user-1' + assert received[0].values == {'priority': 'urgent'} assert result['message_id'] in adapter.interaction_callback_contexts @@ -566,13 +609,22 @@ async def test_dingtalk_stream_callback_accepts_object_content_and_retries_unpar async def listener(event, _adapter): received.append(event) - adapter.register_listener(platform_events.PlatformSpecificEvent, listener) - result = await adapter.call_platform_api( - 'interaction.request', + adapter.register_listener(platform_events.InteractionCallbackEvent, listener) + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, - 'request': {'fields': [{'id': 'priority', 'type': 'select', 'options': [{'value': 'urgent'}]}]}, + 'request': { + 'fields': [ + { + 'id': 'priority', + 'label': 'Priority', + 'type': 'select', + 'options': [{'value': 'urgent', 'label': 'Urgent'}], + } + ] + }, }, ) raw = { @@ -593,51 +645,35 @@ async def test_dingtalk_stream_callback_accepts_object_content_and_retries_unpar message = SimpleNamespace(data=raw, headers=SimpleNamespace(time=1_714_000_000_000)) assert (await handler.process(message))[0] == 200 - assert received[0].data['values'] == {'priority': 'urgent'} + assert received[0].values == {'priority': 'urgent'} raw['content'] = {'actionData': {'unexpected': True}} assert (await handler.process(message))[0] == 500 @pytest.mark.asyncio -async def test_dingtalk_interaction_update_reuses_existing_a2ui_card(): +async def test_dingtalk_expired_interaction_updates_read_only_card(): adapter = make_adapter() - - result = await adapter.call_platform_api( - 'interaction.request', + result = await _update_interaction( + adapter, { - 'callback_token': 'new-token', - 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, + 'state': 'expired', + 'request': {'title': 'Approve?', 'actions': [{'id': 'approve', 'label': 'Approve'}]}, + 'submission': {}, 'update_target': {'message_id': 'existing-track-id', 'rich': True}, - 'request': { - 'interaction_id': 'form-2', - 'title': 'Updated approval', - 'actions': [{'id': 'approve', 'label': 'Approve', 'style': 'primary'}], - 'fallback_text': 'Reply approve.', - }, }, ) - - adapter.bot.create_and_deliver_ai_card.assert_not_awaited() - kwargs = adapter.bot.update_ai_card.await_args.kwargs - assert kwargs['out_track_id'] == 'existing-track-id' - assert kwargs['flow_status'] == 'CONFIRMING' - assert 'updateDataModel' in kwargs['a2ui_messages'][0] - assert 'updateComponents' in kwargs['a2ui_messages'][1] - assert result == { - 'ok': True, - 'message_id': 'existing-track-id', - 'rich': True, - 'updated': True, - } - assert adapter.interaction_callback_contexts['existing-track-id']['callback_token'] == 'new-token' + components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components'] + assert result['message_id'] == 'existing-track-id' + assert any(component.get('text') == '\u2705 Expired' for component in components) + assert not any(component['component'] == 'Button' for component in components) @pytest.mark.asyncio async def test_dingtalk_acknowledgement_shows_text_selection_and_clicked_button(): adapter = make_adapter() - assert 'interaction.acknowledge' in adapter.get_supported_apis() - result = await adapter.call_platform_api( - 'interaction.acknowledge', + assert 'update_interaction' in adapter.get_supported_apis() + result = await _update_interaction( + adapter, { 'request': { 'title': 'Review', @@ -677,26 +713,12 @@ async def test_dingtalk_acknowledgement_shows_text_selection_and_clicked_button( {'label': '', 'value': 'Approve'}, ] - next_result = await adapter.call_platform_api( - 'interaction.request', - { - 'callback_token': 'next-token', - 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, - 'update_target': result, - 'request': {'title': 'Next step', 'actions': [{'id': 'finish', 'label': 'Finish'}]}, - }, - ) - components = adapter.bot.update_ai_card.await_args.kwargs['a2ui_messages'][1]['updateComponents']['components'] - assert next_result['submitted_values'] == result['submitted_values'] - assert components[0]['children'][:4] == ['title', 'submitted-0', 'submitted-1', 'submitted-2'] - assert any(component['component'] == 'Button' for component in components) - @pytest.mark.asyncio async def test_dingtalk_field_submit_shows_entered_value_without_submit_action(): adapter = make_adapter() - await adapter.call_platform_api( - 'interaction.acknowledge', + await _update_interaction( + adapter, { 'request': { 'description': 'Please enter your question', @@ -716,8 +738,8 @@ async def test_dingtalk_field_submit_shows_entered_value_without_submit_action() @pytest.mark.asyncio async def test_dingtalk_button_submit_preserves_its_prompt(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.acknowledge', + result = await _update_interaction( + adapter, { 'request': { 'title': 'Human input', @@ -764,9 +786,9 @@ async def test_dingtalk_adapter_dispatches_native_input_submission(): async def listener(event, adapter): calls.append(event) - adapter.register_listener(platform_events.PlatformSpecificEvent, listener) - result = await adapter.call_platform_api( - 'interaction.request', + adapter.register_listener(platform_events.InteractionCallbackEvent, listener) + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, @@ -803,9 +825,9 @@ async def test_dingtalk_adapter_dispatches_native_input_submission(): await adapter._handle_native_event(native) assert len(calls) == 1 - assert calls[0].action == 'interaction.submitted' - assert calls[0].data['callback_token'] == 'callback-token' - assert calls[0].data['values'] == {'score': 42.5} + assert isinstance(calls[0], platform_events.InteractionCallbackEvent) + assert calls[0].callback_token == 'callback-token' + assert calls[0].values == {'score': 42.5} assert result['message_id'] in adapter.interaction_callback_contexts diff --git a/tests/unit_tests/platform/test_discord_eba_adapter.py b/tests/unit_tests/platform/test_discord_eba_adapter.py index 0e776bf1a..29764b11b 100644 --- a/tests/unit_tests/platform/test_discord_eba_adapter.py +++ b/tests/unit_tests/platform/test_discord_eba_adapter.py @@ -22,6 +22,7 @@ from langbot_plugin.api.definition.abstract.platform.event_logger import Abstrac from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.runner.interaction import InteractionRequest, InteractionHandle class DummyLogger(AbstractEventLogger): @@ -100,16 +101,12 @@ async def test_discord_interaction_view_and_delivery(): view = build_interaction_view(request, 'callback-token') assert view.children[0].custom_id == 'lbi:callback-token:a:0' - result = await adapter.call_platform_api( - 'interaction.request', - { - 'request': request, - 'callback_token': 'callback-token', - 'reply_target': {'target_type': 'group', 'target_id': '789'}, - }, + result = await adapter.send_interaction( + 'group', '789', InteractionRequest.model_validate(request), 'callback-token' ) - assert result == {'ok': True, 'message_id': 321, 'rich': True} + assert isinstance(result, InteractionHandle) + assert result.message_id == 321 and result.rich is True assert channel.send.await_args.kwargs['view'].children[0].label == 'Approve' @@ -125,10 +122,31 @@ def test_discord_interaction_component_builds_scoped_host_event(): {'callback_token': 'callback-token', 'action_ref': 0}, ) - assert event.action == 'interaction.submitted' - assert event.data['actor_id'] == '123' - assert event.data['target_type'] == 'group' - assert event.data['target_id'] == '789' + assert isinstance(event, platform_events.InteractionCallbackEvent) + assert event.actor_id == '123' + assert event.target_type == 'group' + assert event.target_id == '789' + + +@pytest.mark.asyncio +async def test_discord_update_interaction_removes_components(): + adapter = make_adapter() + message = SimpleNamespace(edit=AsyncMock()) + channel = SimpleNamespace(fetch_message=AsyncMock(return_value=message)) + adapter._get_channel = AsyncMock(return_value=channel) + handle = InteractionHandle( + message_id=321, + context={ + 'target_id': '789', + 'request': {'title': 'Approve?'}, + }, + ) + + assert 'update_interaction' in adapter.get_supported_apis() + await adapter.update_interaction(handle, 'cancelled') + + channel.fetch_message.assert_awaited_once_with(321) + message.edit.assert_awaited_once_with(content='Approve?\n\nCancelled', view=None) @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_lark_eba_adapter.py b/tests/unit_tests/platform/test_lark_eba_adapter.py index d20d5830a..2aa44f36b 100644 --- a/tests/unit_tests/platform/test_lark_eba_adapter.py +++ b/tests/unit_tests/platform/test_lark_eba_adapter.py @@ -26,6 +26,46 @@ from langbot_plugin.api.definition.abstract.platform.event_logger import Abstrac from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) + + +async def _send_interaction(adapter, params): + target = params['reply_target'] + request = InteractionRequest.model_validate( + { + 'interaction_id': 'test-interaction', + 'title': 'Interaction', + **params['request'], + } + ) + handle = await adapter.send_interaction( + target['target_type'], + target['target_id'], + request, + params['callback_token'], + reply_to=target.get('message_id'), + ) + return handle.model_dump() + + +async def _update_interaction(adapter, params): + handle = InteractionHandle.model_validate( + { + **params['update_target'], + 'context': {'request': params['request']}, + } + ) + submission = InteractionSubmission.model_validate( + { + 'interaction_id': 'test-interaction', + **params['submission'], + } + ) + return (await adapter.update_interaction(handle, params.get('state', 'submitted'), submission)).model_dump() class DummyLogger(AbstractEventLogger): @@ -471,8 +511,8 @@ def test_lark_interaction_card_uses_native_select_and_declares_fields(): async def test_lark_interaction_request_sends_interactive_message(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.request', + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, @@ -495,53 +535,36 @@ async def test_lark_interaction_request_sends_interactive_message(): @pytest.mark.asyncio -async def test_lark_interaction_request_updates_existing_message(): +async def test_lark_expired_interaction_updates_existing_card(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.request', + result = await _update_interaction( + adapter, { - 'callback_token': 'next-token', - 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, - 'update_target': { - 'message_id': 'card-message-1', - 'card_id': 'card-id', - 'sequence': 0, - 'rich': True, - }, - 'request': { - 'interaction_id': 'form-2', - 'title': 'Choose action', - 'actions': [{'id': 'approve', 'label': 'Approve'}], - 'fallback_text': 'Reply approve.', - }, + 'state': 'expired', + 'update_target': {'message_id': 'card-message-1', 'card_id': 'card-id', 'sequence': 0, 'rich': True}, + 'request': {'title': 'Choose action', 'actions': [{'id': 'approve', 'label': 'Approve'}]}, + 'submission': {}, }, ) - assert result == { - 'ok': True, - 'message_id': 'card-message-1', - 'card_id': 'card-id', - 'sequence': 1, - 'rich': True, - 'updated': True, - } + assert result['message_id'] == 'card-message-1' + assert result['sequence'] == 1 adapter.api_client.im.v1.message.acreate.assert_not_awaited() update_request = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0] assert update_request.card_id == 'card-id' assert update_request.request_body.sequence == 1 card = json.loads(update_request.request_body.card.data) assert card['header']['title']['content'] == 'Choose action' - button = card['body']['elements'][-1]['columns'][0]['elements'][0] - assert button['behaviors'][0]['value']['lbi'] == 'next-token' + assert 'Expired' in card['body']['elements'][-1]['content'] @pytest.mark.asyncio async def test_lark_interaction_acknowledge_replaces_controls_with_summary(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.acknowledge', + result = await _update_interaction( + adapter, { 'update_target': { 'message_id': 'card-message-1', @@ -565,7 +588,7 @@ async def test_lark_interaction_acknowledge_replaces_controls_with_summary(): @pytest.mark.asyncio -async def test_lark_interaction_keeps_prior_values_through_next_action_and_final_acknowledgement(): +async def test_lark_interaction_update_keeps_prior_values(): adapter = make_adapter() update_target = { 'message_id': 'card-message-1', @@ -575,30 +598,10 @@ async def test_lark_interaction_keeps_prior_values_through_next_action_and_final 'submitted_values': [{'label': 'Question', 'value': '123'}], } - action_result = await adapter.call_platform_api( - 'interaction.request', + final_result = await _update_interaction( + adapter, { - 'callback_token': 'next-token', - 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, 'update_target': update_target, - 'request': { - 'interaction_id': 'action-1', - 'title': 'Manual review', - 'actions': [{'id': 'or', 'label': 'or'}], - 'fallback_text': 'Reply or.', - }, - }, - ) - - action_update = adapter.api_client.cardkit.v1.card.aupdate.await_args.args[0] - action_card = json.loads(action_update.request_body.card.data) - assert '✅ Question:123' in action_card['body']['elements'][0]['content'] - assert action_result['submitted_values'] == [{'label': 'Question', 'value': '123'}] - - final_result = await adapter.call_platform_api( - 'interaction.acknowledge', - { - 'update_target': action_result, 'request': { 'title': 'Manual review', 'actions': [{'id': 'or', 'label': 'or'}], @@ -844,7 +847,7 @@ async def test_lark_streaming_card_falls_back_to_full_card_update_when_stream_cl async def test_lark_hitl_reply_stream_creates_and_updates_new_card(target_type, target_id, receive_id_type): adapter = make_adapter({'enable-stream-reply': True}) event = SimpleNamespace( - event_type='interaction.submitted', + event_type='message.received', delivery=SimpleNamespace( surface='platform', platform_capabilities={}, @@ -881,12 +884,12 @@ def test_lark_interaction_callback_builds_scoped_host_event(): event = interaction_event_from_callback(callback) - assert event.action == 'interaction.submitted' - assert event.data['callback_token'] == 'callback-token' - assert event.data['action_ref'] == 1 - assert event.data['actor_id'] == 'user-1' - assert event.data['target_id'] == 'chat-1' - assert event.data['message_id'] == 'card-message-1' + assert isinstance(event, platform_events.InteractionCallbackEvent) + assert event.callback_token == 'callback-token' + assert event.raw_submission['action_ref'] == 1 + assert event.actor_id == 'user-1' + assert event.target_id == 'chat-1' + assert event.callback_context['message_id'] == 'card-message-1' def test_lark_native_form_callback_submits_typed_field_value(): @@ -908,10 +911,10 @@ def test_lark_native_form_callback_submits_typed_field_value(): event = interaction_event_from_callback(callback) - assert event.data['callback_token'] == 'callback-token' - assert event.data['target_type'] == 'person' - assert event.data['target_id'] == 'user-1' - assert event.data['values'] == {'score': 42.5} + assert event.callback_token == 'callback-token' + assert event.target_type == 'person' + assert event.target_id == 'user-1' + assert event.values == {'score': 42.5} @pytest.mark.asyncio @@ -922,7 +925,7 @@ async def test_lark_webhook_dispatches_native_form_submission(): async def listener(event, adapter): calls.append(event) - adapter.register_listener(platform_events.PlatformSpecificEvent, listener) + adapter.register_listener(platform_events.InteractionCallbackEvent, listener) payload = { 'schema': '2.0', 'header': {'event_type': 'card.action.trigger'}, @@ -948,10 +951,10 @@ async def test_lark_webhook_dispatches_native_form_submission(): assert response['card']['type'] == 'raw' assert response['card']['data']['elements'][0]['text']['content'] == '**Submitted**' assert len(calls) == 1 - assert calls[0].action == 'interaction.submitted' - assert calls[0].data['callback_token'] == 'callback-token' - assert calls[0].data['values'] == {'comment': 'Looks good'} - assert calls[0].data['message_id'] == 'card-message-1' + assert isinstance(calls[0], platform_events.InteractionCallbackEvent) + assert calls[0].callback_token == 'callback-token' + assert calls[0].values == {'comment': 'Looks good'} + assert calls[0].callback_context['message_id'] == 'card-message-1' @pytest.mark.asyncio @@ -961,7 +964,7 @@ async def test_lark_webhook_cardkit_submission_uses_async_card_update_only(): async def listener(event, adapter): pass - adapter.register_listener(platform_events.PlatformSpecificEvent, listener) + adapter.register_listener(platform_events.InteractionCallbackEvent, listener) payload = { 'schema': '2.0', 'header': {'event_type': 'card.action.trigger'}, diff --git a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py index ee1933ed7..9028c7b2e 100644 --- a/tests/unit_tests/platform/test_qqofficial_eba_adapter.py +++ b/tests/unit_tests/platform/test_qqofficial_eba_adapter.py @@ -21,6 +21,7 @@ from langbot_plugin.api.definition.abstract.platform.event_logger import Abstrac from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.runner.interaction import InteractionRequest, InteractionHandle class DummyLogger(AbstractEventLogger): @@ -205,10 +206,12 @@ async def test_qqofficial_interaction_delivery_and_callback_event(): 'fallback_text': 'Reply approve.', }, } - result = await adapter.call_platform_api('interaction.request', params) - await adapter.call_platform_api('interaction.request', params) + request = InteractionRequest.model_validate(params['request']) + result = await adapter.send_interaction('group', 'group-openid', request, 'callback-token', reply_to='msg-1') + await adapter.send_interaction('group', 'group-openid', request, 'callback-token', reply_to='msg-1') - assert result == {'ok': True, 'message_id': 'keyboard-message', 'rich': True} + assert isinstance(result, InteractionHandle) + assert result.message_id == 'keyboard-message' and result.rich is True calls = adapter.bot.send_markdown_keyboard.await_args_list assert [call.kwargs['msg_id'] for call in calls] == ['msg-1', 'msg-1'] assert [call.kwargs['msg_seq'] for call in calls] == [1, 2] @@ -222,31 +225,28 @@ async def test_qqofficial_interaction_delivery_and_callback_event(): 'data': {'resolved': {'button_data': 'lbi:callback-token:a:0'}}, } ) - assert event.action == 'interaction.submitted' - assert event.data['action_ref'] == 0 - assert event.data['actor_id'] == 'user-openid' + assert isinstance(event, platform_events.InteractionCallbackEvent) + assert event.raw_submission['action_ref'] == 0 + assert event.actor_id == 'user-openid' @pytest.mark.asyncio async def test_qqofficial_interaction_omits_synthetic_negative_message_id(): adapter = make_adapter() - await adapter.call_platform_api( - 'interaction.request', - { - 'callback_token': 'callback-token', - 'reply_target': { - 'target_type': 'person', - 'target_id': 'user-openid', - 'message_id': -1, - }, - 'request': { + await adapter.send_interaction( + 'person', + 'user-openid', + InteractionRequest.model_validate( + { 'interaction_id': 'form-1', 'title': 'Approve?', 'actions': [{'id': 'approve', 'label': 'Approve'}], 'fallback_text': 'Reply approve.', - }, - }, + } + ), + 'callback-token', + reply_to=-1, ) call = adapter.bot.send_markdown_keyboard.await_args @@ -255,6 +255,28 @@ async def test_qqofficial_interaction_omits_synthetic_negative_message_id(): assert call.kwargs['msg_seq'] == 1 +@pytest.mark.asyncio +async def test_qqofficial_update_interaction_reports_expiry_without_edit_api(): + adapter = make_adapter() + send = AsyncMock() + object.__setattr__(adapter, 'send_message', send) + handle = InteractionHandle( + message_id='card-1', + context={ + 'target_type': 'group', + 'target_id': 'group-openid', + 'request': {'title': 'Approve?'}, + }, + ) + + assert adapter.get_interaction_capabilities()['supports_updates'] is False + assert 'update_interaction' in adapter.get_supported_apis() + await adapter.update_interaction(handle, 'expired') + + assert send.await_args.args[:2] == ('group', 'group-openid') + assert send.await_args.args[2].root[0].text == 'Approve?: Expired' + + @pytest.mark.asyncio async def test_qqofficial_message_converter_maps_common_components_to_send_payloads(): payload = await QQOfficialMessageConverter.yiri2target( diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index 147e41bd1..b467d7e8a 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -169,7 +169,9 @@ class TestEventRouteTrace: bot.ap = SimpleNamespace( workspace_service=active_workspace_service(), agent_service=SimpleNamespace(get_agent=AsyncMock(side_effect=[malformed_agent, valid_agent])), - agent_run_orchestrator=SimpleNamespace(run=fake_run), + agent_run_orchestrator=SimpleNamespace( + run=fake_run, try_claim_steering_from_event=AsyncMock(return_value=False) + ), ) from langbot_plugin.api.entities.builtin.platform.events import EBAEvent @@ -486,253 +488,6 @@ class TestEBAEventBindings: assert binding.resource_policy.allowed_kb_uuids == ['kb-1'] -class TestInteractionResumeRouting: - """Interaction callbacks resume the processor that created the request.""" - - @staticmethod - def _event(): - from langbot_plugin.api.entities.builtin.platform.events import PlatformSpecificEvent - - return PlatformSpecificEvent( - action='interaction.submitted', - timestamp=1234, - data={ - 'callback_token': 'callback-token', - 'interaction_id': 'form-1', - 'action_id': 'approve', - 'values': {'name': 'Alice'}, - 'actor_id': 'user-1', - 'target_type': 'group', - 'target_id': 'chat-1', - 'message_id': 'card-message-1', - }, - ) - - @staticmethod - def _record(processor_type: str): - return { - 'id': 1, - 'interaction_id': 'form-1', - 'binding_id': 'binding-1', - 'runner_id': 'plugin:test/Dify/default', - 'processor_type': processor_type, - 'processor_id': f'{processor_type}-1', - 'workspace_id': None, - 'conversation_id': 'group_chat-1', - 'thread_id': None, - 'delivery_target': {'target_type': 'group', 'target_id': 'chat-1'}, - 'submission': { - 'interaction_id': 'form-1', - 'action_id': 'approve', - 'values': {'name': 'Alice'}, - 'submitted_at': 1234, - }, - } - - @staticmethod - def _make_bot(record): - from langbot.pkg.platform.botmgr import RuntimeBot - - bot = object.__new__(RuntimeBot) - bot.bot_entity = SimpleNamespace(uuid='bot-1', name='Test', event_bindings=[]) - bot.execution_context = TEST_CONTEXT - bot.workspace_uuid = TEST_CONTEXT.workspace_uuid - bot.placement_generation = TEST_CONTEXT.placement_generation - interaction_manager = SimpleNamespace( - consume_callback=AsyncMock(return_value=record), - acknowledge_submission=AsyncMock(), - ) - bot.ap = SimpleNamespace( - agent_run_orchestrator=SimpleNamespace(interaction_manager=interaction_manager), - msg_aggregator=SimpleNamespace(add_message=AsyncMock()), - pipeline_service=SimpleNamespace( - get_pipeline=AsyncMock( - return_value={ - 'uuid': record['processor_id'], - 'config': { - 'ai': { - 'runner': {'id': record['runner_id']}, - 'runner_config': {record['runner_id']: {}}, - } - }, - } - ) - ), - ) - return bot - - @pytest.mark.asyncio - async def test_pipeline_callback_bypasses_route_table_and_targets_original_pipeline(self): - bot = self._make_bot(self._record('pipeline')) - adapter = SimpleNamespace() - - await bot._handle_interaction_submission(self._event(), adapter) - - bot.ap.msg_aggregator.add_message.assert_awaited_once() - kwargs = bot.ap.msg_aggregator.add_message.await_args.kwargs - assert kwargs['pipeline_uuid'] == 'pipeline-1' - assert kwargs['routed_by_rule'] is True - assert kwargs['variables']['_interaction_submission']['interaction_id'] == 'form-1' - assert kwargs['message_chain'].message_id == 'card-message-1' - assert kwargs['message_event'].message_chain.message_id == 'card-message-1' - manager = bot.ap.agent_run_orchestrator.interaction_manager - manager.consume_callback.assert_awaited_once_with( - callback_token='callback-token', - submission={ - 'interaction_id': 'form-1', - 'action_id': 'approve', - 'values': {'name': 'Alice'}, - 'submitted_at': 1234, - }, - bot_id='bot-1', - conversation_id='group_chat-1', - actor_id='user-1', - ) - - @pytest.mark.asyncio - async def test_wecom_callback_passes_native_card_update_context(self): - record = self._record('pipeline') - bot = self._make_bot(record) - adapter = SimpleNamespace() - event = self._event() - event.adapter_name = 'wecombot-omni' - event.data.update({'callback_req_id': 'callback-req', 'callback_task_id': 'task-1'}) - - await bot._handle_interaction_submission(event, adapter) - - bot.ap.agent_run_orchestrator.interaction_manager.acknowledge_submission.assert_awaited_once_with( - record, adapter, {'req_id': 'callback-req', 'task_id': 'task-1'} - ) - - @pytest.mark.asyncio - async def test_callback_forwards_compact_platform_references(self): - bot = self._make_bot(self._record('pipeline')) - adapter = SimpleNamespace() - event = self._event() - event.data.pop('interaction_id') - event.data.pop('action_id') - event.data['action_ref'] = 1 - - await bot._handle_interaction_submission(event, adapter) - - manager = bot.ap.agent_run_orchestrator.interaction_manager - submission = manager.consume_callback.await_args.kwargs['submission'] - assert submission['action_ref'] == 1 - assert submission['interaction_id'] is None - - @pytest.mark.asyncio - async def test_replayed_callback_is_an_idempotent_noop(self): - from langbot.pkg.agent.runner.interaction_store import InteractionAlreadySubmittedError - - bot = self._make_bot(self._record('pipeline')) - manager = bot.ap.agent_run_orchestrator.interaction_manager - manager.consume_callback.side_effect = InteractionAlreadySubmittedError('Interaction was already submitted') - - await bot._handle_interaction_submission(self._event(), SimpleNamespace()) - - manager.acknowledge_submission.assert_not_awaited() - bot.ap.msg_aggregator.add_message.assert_not_awaited() - - @pytest.mark.asyncio - async def test_pipeline_callback_rejects_changed_runner(self): - bot = self._make_bot(self._record('pipeline')) - bot.ap.pipeline_service.get_pipeline.return_value['config']['ai']['runner']['id'] = 'plugin:test/Other/default' - - with pytest.raises(ValueError, match='Pipeline runner changed'): - await bot._handle_interaction_submission(self._event(), SimpleNamespace()) - - bot.ap.msg_aggregator.add_message.assert_not_awaited() - - @pytest.mark.asyncio - async def test_agent_callback_targets_original_agent_and_runner(self): - import langbot_plugin.api.entities.builtin.provider.message as provider_message - - captured = [] - - async def fake_run(envelope, binding, adapter_context=None): - captured.append((envelope, binding, adapter_context)) - yield provider_message.Message(role='assistant', content='approved') - - bot = self._make_bot(self._record('agent')) - bot.ap.agent_service = SimpleNamespace( - get_agent=AsyncMock( - return_value={ - 'uuid': 'agent-1', - 'kind': 'agent', - 'enabled': True, - 'config': { - 'runner': {'id': 'plugin:test/Dify/default'}, - 'runner_config': {'plugin:test/Dify/default': {}}, - }, - } - ) - ) - bot.ap.agent_run_orchestrator.run = fake_run - adapter = SimpleNamespace( - get_supported_apis=Mock(return_value=['send_message']), - send_message=AsyncMock(), - ) - - event = self._event() - await bot._handle_interaction_submission(event, adapter) - - envelope, binding, adapter_context = captured[0] - assert envelope.event_type == 'interaction.submitted' - assert envelope.data['interaction']['action_id'] == 'approve' - assert binding.binding_id == 'binding-1' - assert binding.processor_type == 'agent' - assert binding.processor_id == 'agent-1' - assert adapter_context == { - '_delivery_adapter': adapter, - '_platform_event': event, - '_execution_context': TEST_CONTEXT, - } - assert envelope.delivery.supports_streaming is False - assert binding.delivery_policy.enable_reply is True - adapter.send_message.assert_not_awaited() - - from langbot_plugin.api.entities.builtin.platform import entities, events, message - - text_record = self._record('agent') - text_record['submission'] = { - 'interaction_id': 'form-1', - 'action_id': None, - 'values': {'name': 'My question'}, - 'submitted_at': 1235, - } - text_event = events.MessageReceivedEvent( - timestamp=1235, - message_id='message-2', - message_chain=message.MessageChain([message.Plain(text='My question')]), - sender=entities.User(id='user-1'), - chat_type=entities.ChatType.GROUP, - chat_id='chat-1', - ) - await bot._resume_agent_interaction(text_record, text_event, adapter, 'user-1') - assert captured[1][0].input.text == 'My question' - assert captured[1][0].input.interaction.values == {'name': 'My question'} - - def test_agent_product_to_binding_does_not_fallback_to_component_ref(self): - """An empty config runner stays unconfigured even if component_ref is stale.""" - from langbot.pkg.platform.botmgr import RuntimeBot - - binding = RuntimeBot._agent_product_to_binding( - { - 'uuid': 'agent-1', - 'component_ref': 'plugin:test/stale/default', - 'config': { - 'runner': {'id': ''}, - 'runner_config': {}, - }, - }, - {'id': 'binding-1'}, - 'platform.member.joined', - 'bot-1', - ) - - assert binding is None - - def test_websocket_task_override_does_not_mutate_bot_default(): from langbot.pkg.platform.botmgr import RuntimeBot @@ -753,6 +508,83 @@ def test_websocket_task_override_does_not_mutate_bot_default(): assert bot.bot_entity.use_pipeline_uuid == 'default-uuid' +@pytest.mark.asyncio +async def test_interaction_callback_wakes_original_run_without_new_route(): + from langbot.pkg.platform.botmgr import RuntimeBot + from langbot_plugin.api.entities.builtin.platform.events import InteractionCallbackEvent + + record = {'run_id': 'run-1', 'interaction_id': 'form-1', 'submission': {'action_id': 'approve'}} + manager = SimpleNamespace( + consume_callback=AsyncMock(return_value=record), + wake_waiter=Mock(return_value=True), + ) + bot = object.__new__(RuntimeBot) + bot.bot_entity = SimpleNamespace(uuid='bot-1') + bot.ap = SimpleNamespace(agent_run_orchestrator=SimpleNamespace(interaction_manager=manager)) + adapter = SimpleNamespace(update_interaction=AsyncMock()) + event = InteractionCallbackEvent( + adapter_name='wecombot-omni', + callback_token='token-1', + actor_id='user-1', + target_type='group', + target_id='chat-1', + action_id='approve', + callback_context={'req_id': 'request-1', 'task_id': 'task-1'}, + raw_submission={'action_ref': 0}, + ) + + await bot._handle_interaction_submission(event, adapter) + + manager.consume_callback.assert_awaited_once_with( + callback_token='token-1', + submission={'action_id': 'approve', 'values': {}, 'action_ref': 0}, + bot_id='bot-1', + conversation_id='group_chat-1', + actor_id='user-1', + ) + manager.wake_waiter.assert_called_once_with( + record, + 'user-1', + {'req_id': 'request-1', 'task_id': 'task-1'}, + ) + adapter.update_interaction.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_restart_expired_callback_invalidates_old_card(): + from langbot.pkg.agent.runner.interaction_store import InteractionExpiredError + from langbot.pkg.platform.botmgr import RuntimeBot + from langbot_plugin.api.entities.builtin.platform.events import InteractionCallbackEvent + + stale = { + 'request': {'title': 'Approve?'}, + 'delivery_result': {'message_id': 'card-1', 'context': {'request': {'title': 'Approve?'}}}, + } + manager = SimpleNamespace( + consume_callback=AsyncMock(side_effect=InteractionExpiredError('cancelled')), + store=SimpleNamespace(get_by_callback_token=AsyncMock(return_value=stale)), + ) + bot = object.__new__(RuntimeBot) + bot.bot_entity = SimpleNamespace(uuid='bot-1') + bot.ap = SimpleNamespace(agent_run_orchestrator=SimpleNamespace(interaction_manager=manager)) + adapter = SimpleNamespace(update_interaction=AsyncMock()) + event = InteractionCallbackEvent( + adapter_name='lark-omni', + callback_token='old-token', + actor_id='user-1', + target_type='group', + target_id='chat-1', + callback_context={'message_id': 'card-1'}, + ) + + await bot._handle_interaction_submission(event, adapter) + + handle, state = adapter.update_interaction.await_args.args + assert handle.message_id == 'card-1' + assert handle.context['callback_context'] == {'message_id': 'card-1'} + assert state == 'expired' + + @pytest.mark.asyncio async def test_installed_event_processor_never_receives_unbound_events(): from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent @@ -794,7 +626,7 @@ async def test_bound_event_processor_receives_one_complete_typed_event(): } ) ), - agent_run_orchestrator=SimpleNamespace(run=run), + agent_run_orchestrator=SimpleNamespace(run=run, try_claim_steering_from_event=AsyncMock(return_value=False)), plugin_connector=SimpleNamespace(emit_event=AsyncMock()), ) bot.ap.runner_registry = SimpleNamespace( @@ -818,53 +650,6 @@ async def test_bound_event_processor_receives_one_complete_typed_event(): bot.ap.plugin_connector.emit_event.assert_not_called() -@pytest.mark.asyncio -async def test_wecom_plain_reply_resumes_pending_agent_interaction(): - from datetime import datetime - - from langbot_plugin.api.entities.builtin.platform import entities, events, message - - bot = TestEventRouteTrace._make_bot( - [{'id': 'binding-1', 'event_pattern': 'message.received', 'target_type': 'agent', 'target_uuid': 'agent-1'}] - ) - run = Mock() - consume = AsyncMock(return_value={'interaction_id': 'form-1'}) - bot.ap = SimpleNamespace( - agent_service=SimpleNamespace( - get_agent=AsyncMock( - return_value={ - 'uuid': 'agent-1', - 'kind': 'agent', - 'supported_event_patterns': ['message.received'], - 'config': {'runner': {'id': 'runner-1'}, 'runner_config': {'runner-1': {}}}, - } - ) - ), - agent_run_orchestrator=SimpleNamespace( - run=run, interaction_manager=SimpleNamespace(store=SimpleNamespace(consume_text_reply=consume)) - ), - ) - bot._resume_agent_interaction = AsyncMock() - event = events.MessageReceivedEvent( - adapter_name='wecombot-omni', - message_id='message-1', - message_chain=message.MessageChain( - [message.Source(id='message-1', time=datetime.now()), message.Plain(text='answer')] - ), - sender=entities.User(id='user-1'), - chat_type=entities.ChatType.PRIVATE, - chat_id='user-1', - ) - - result = await bot._dispatch_eba_event_to_processor(event, SimpleNamespace(get_supported_apis=lambda: [])) - - assert result['status'] == 'delivered' - assert consume.await_args.kwargs['text'] == 'answer' - assert consume.await_args.kwargs['conversation_id'] == 'person_user-1' - bot._resume_agent_interaction.assert_awaited_once() - run.assert_not_called() - - @pytest.mark.asyncio @pytest.mark.parametrize('kind', ['agent', 'event_processor']) @pytest.mark.parametrize('output_kind', ['message', 'chunks', 'tool_rounds']) @@ -931,7 +716,7 @@ async def test_processor_output_delivery_policy(kind, output_kind, explicit_repl } ) ), - agent_run_orchestrator=SimpleNamespace(run=run), + agent_run_orchestrator=SimpleNamespace(run=run, try_claim_steering_from_event=AsyncMock(return_value=False)), ) event = events.MessageReceivedEvent( message_id='message-1', diff --git a/tests/unit_tests/platform/test_telegram_eba_adapter.py b/tests/unit_tests/platform/test_telegram_eba_adapter.py index 3c2a0a15f..f5668d721 100644 --- a/tests/unit_tests/platform/test_telegram_eba_adapter.py +++ b/tests/unit_tests/platform/test_telegram_eba_adapter.py @@ -23,6 +23,7 @@ from langbot_plugin.api.definition.abstract.platform.event_logger import Abstrac from langbot_plugin.api.entities.builtin.platform import entities as platform_entities from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message +from langbot_plugin.api.entities.builtin.runner.interaction import InteractionRequest, InteractionHandle from langbot_plugin.api.entities import events as plugin_events @@ -185,12 +186,11 @@ async def test_telegram_interaction_request_renders_action_buttons_and_thread_ta bot = SimpleNamespace(send_message=AsyncMock(return_value=sent)) object.__setattr__(adapter, 'bot', bot) - result = await adapter.call_platform_api( - 'interaction.request', - { - 'callback_token': 'callback-token', - 'reply_target': {'target_type': 'group', 'target_id': '-1001#7'}, - 'request': { + result = await adapter.send_interaction( + 'group', + '-1001#7', + InteractionRequest.model_validate( + { 'interaction_id': 'form-1', 'kind': 'choice', 'title': 'Approve?', @@ -199,11 +199,13 @@ async def test_telegram_interaction_request_renders_action_buttons_and_thread_ta {'id': 'reject', 'label': 'Reject', 'style': 'danger'}, ], 'fallback_text': 'Reply approve or reject.', - }, - }, + } + ), + 'callback-token', ) - assert result == {'ok': True, 'message_id': 321, 'rich': True} + assert isinstance(result, InteractionHandle) + assert result.message_id == 321 and result.rich is True kwargs = bot.send_message.await_args.kwargs assert kwargs['chat_id'] == -1001 assert kwargs['message_thread_id'] == 7 @@ -235,19 +237,47 @@ async def test_telegram_interaction_request_renders_select_or_falls_back(): }, } - result = await adapter.call_platform_api('interaction.request', base_params) - assert result['rich'] is True + result = await adapter.send_interaction( + 'person', '123', InteractionRequest.model_validate(base_params['request']), 'callback-token' + ) + assert result.rich is True button = bot.send_message.await_args.kwargs['reply_markup'].inline_keyboard[0][0] assert button.callback_data == 'lbi:callback-token:f:0:0' base_params['request']['fields'][0]['type'] = 'text' - result = await adapter.call_platform_api('interaction.request', base_params) - assert result['rich'] is False + result = await adapter.send_interaction( + 'person', '123', InteractionRequest.model_validate(base_params['request']), 'callback-token' + ) + assert result.rich is False kwargs = bot.send_message.await_args.kwargs assert 'reply_markup' not in kwargs assert 'Reply with a priority.' in kwargs['text'] +@pytest.mark.asyncio +async def test_telegram_update_interaction_removes_buttons(): + adapter = make_adapter() + bot = SimpleNamespace(edit_message_text=AsyncMock()) + object.__setattr__(adapter, 'bot', bot) + handle = InteractionHandle( + message_id=321, + context={ + 'target_id': '-1001#7', + 'request': {'title': 'Approve?'}, + }, + ) + + assert 'update_interaction' in adapter.get_supported_apis() + await adapter.update_interaction(handle, 'expired') + + bot.edit_message_text.assert_awaited_once_with( + chat_id=-1001, + message_id=321, + text='Approve?\n\nExpired', + reply_markup=None, + ) + + def test_telegram_interaction_callback_builds_scoped_host_event(): update = SimpleNamespace( callback_query=SimpleNamespace( @@ -264,12 +294,12 @@ def test_telegram_interaction_callback_builds_scoped_host_event(): {'callback_token': 'callback-token', 'action_ref': 1}, ) - assert event.action == 'interaction.submitted' - assert event.data['callback_token'] == 'callback-token' - assert event.data['action_ref'] == 1 - assert event.data['actor_id'] == '456' - assert event.data['target_type'] == 'group' - assert event.data['target_id'] == '-1001#7' + assert isinstance(event, platform_events.InteractionCallbackEvent) + assert event.callback_token == 'callback-token' + assert event.raw_submission['action_ref'] == 1 + assert event.actor_id == '456' + assert event.target_type == 'group' + assert event.target_id == '-1001#7' @pytest.mark.asyncio @@ -305,8 +335,8 @@ async def test_telegram_interaction_callback_answers_and_dispatches_once(): query.edit_message_reply_markup.assert_awaited_once_with(reply_markup=None) listener.assert_awaited_once() event = listener.await_args.args[0] - assert event.action == 'interaction.submitted' - assert event.data['action_ref'] == 0 + assert isinstance(event, platform_events.InteractionCallbackEvent) + assert event.raw_submission['action_ref'] == 0 @pytest.mark.asyncio diff --git a/tests/unit_tests/platform/test_wecombot_eba_adapter.py b/tests/unit_tests/platform/test_wecombot_eba_adapter.py index a0c8b2de5..179f54428 100644 --- a/tests/unit_tests/platform/test_wecombot_eba_adapter.py +++ b/tests/unit_tests/platform/test_wecombot_eba_adapter.py @@ -21,6 +21,46 @@ from langbot_plugin.api.entities.builtin.platform import entities as platform_en from langbot_plugin.api.entities.builtin.platform import events as platform_events from langbot_plugin.api.entities.builtin.platform import message as platform_message from langbot_plugin.api.entities.builtin.platform.errors import NotSupportedError +from langbot_plugin.api.entities.builtin.runner.interaction import ( + InteractionHandle, + InteractionRequest, + InteractionSubmission, +) + + +async def _send_interaction(adapter, params): + target = params['reply_target'] + request = InteractionRequest.model_validate( + { + 'interaction_id': 'test-interaction', + 'title': 'Interaction', + **params['request'], + } + ) + handle = await adapter.send_interaction( + target['target_type'], + target['target_id'], + request, + params['callback_token'], + reply_to=target.get('message_id'), + ) + return handle.model_dump() + + +async def _update_interaction(adapter, params): + handle = InteractionHandle.model_validate( + { + **params['update_target'], + 'context': {'request': params['request'], 'callback_context': params.get('callback_context') or {}}, + } + ) + submission = InteractionSubmission.model_validate( + { + 'interaction_id': 'test-interaction', + **params['submission'], + } + ) + return (await adapter.update_interaction(handle, params.get('state', 'submitted'), submission)).model_dump() class DummyLogger(AbstractEventLogger): @@ -116,8 +156,8 @@ def make_adapter(enable_webhook: bool = False) -> WecomBotAdapter: @pytest.mark.asyncio async def test_interaction_card_finishes_original_loading_stream(): adapter = make_adapter() - await adapter.call_platform_api( - 'interaction.request', + await _send_interaction( + adapter, { 'request': {'title': 'Continue', 'actions': [{'id': 'ok', 'label': 'OK'}]}, 'reply_target': {'target_type': 'person', 'target_id': 'user-1', 'message_id': 'original-1'}, @@ -214,8 +254,8 @@ async def test_wecombot_select_card_submits_native_dropdown_value(): ], 'fallback_text': 'Choose priority.', } - result = await adapter.call_platform_api( - 'interaction.request', + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, @@ -254,19 +294,19 @@ async def test_wecombot_select_card_submits_native_dropdown_value(): }, } event = interaction_event_from_native(native) - assert event.data['callback_token'] == 'callback-token' - assert event.data['callback_req_id'] == 'req-1' - assert event.data['callback_task_id'] == result['task_id'] + assert event.callback_token == 'callback-token' + assert event.callback_context['req_id'] == 'req-1' + assert event.callback_context['task_id'] == result['task_id'] resolved = InteractionManager._resolve_submission_refs( - {'interaction_id': request['interaction_id'], 'request': request}, event.data + {'interaction_id': request['interaction_id'], 'request': request}, event.raw_submission ) assert resolved['values'] == {'priority': 'urgent'} listener = AsyncMock() - adapter.register_listener(platform_events.PlatformSpecificEvent, listener) + adapter.register_listener(platform_events.InteractionCallbackEvent, listener) await adapter._handle_interaction_event(native) listener.assert_awaited_once() - assert listener.await_args.args[0].data['option_ref'] == 1 + assert listener.await_args.args[0].raw_submission['option_ref'] == 1 @pytest.mark.asyncio @@ -289,8 +329,8 @@ async def test_wecombot_acknowledges_submitted_card(kind): ], } submission = {'action_id': None, 'values': {'choice': 'b'}} - delivered = await adapter.call_platform_api( - 'interaction.request', + delivered = await _send_interaction( + adapter, { 'callback_token': 'token-1', 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, @@ -298,8 +338,8 @@ async def test_wecombot_acknowledges_submitted_card(kind): }, ) - result = await adapter.call_platform_api( - 'interaction.acknowledge', + result = await _update_interaction( + adapter, { 'request': request, 'submission': submission, @@ -321,8 +361,8 @@ async def test_wecombot_acknowledges_submitted_card(kind): assert all(button['key'] == 'submitted' for button in card['button_list']) with pytest.raises(ValueError, match='does not match'): - await adapter.call_platform_api( - 'interaction.acknowledge', + await _update_interaction( + adapter, { 'request': request, 'submission': submission, @@ -380,7 +420,7 @@ def test_wecombot_select_accepts_legacy_response_data(): 'ResponseData': '{"select_list":[{"question_key":"f0","option_id":"opt_2"}]}', }, } - assert interaction_event_from_native(native).data['option_ref'] == 1 + assert interaction_event_from_native(native).raw_submission['option_ref'] == 1 @pytest.mark.asyncio @@ -389,8 +429,8 @@ async def test_wecombot_interaction_delivery_rejects_failed_ack(): adapter.bot.send_template_card.return_value = {'errcode': 40001, 'errmsg': 'invalid card'} with pytest.raises(RuntimeError, match='invalid card'): - await adapter.call_platform_api( - 'interaction.request', + await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'person', 'target_id': 'user-1'}, @@ -414,8 +454,8 @@ async def test_wecombot_interaction_delivery_rejects_failed_ack(): @pytest.mark.asyncio async def test_wecombot_interaction_delivery_and_callback_event(): adapter = make_adapter() - result = await adapter.call_platform_api( - 'interaction.request', + result = await _send_interaction( + adapter, { 'callback_token': 'callback-token', 'reply_target': {'target_type': 'group', 'target_id': 'group-1'}, @@ -437,14 +477,14 @@ async def test_wecombot_interaction_delivery_and_callback_event(): 'template_card_event': {'EventKey': 'lbi:callback-token:a:0'}, } event = interaction_event_from_native(native) - assert event.action == 'interaction.submitted' - assert event.data['action_ref'] == 0 - assert event.data['target_id'] == 'group-1' + assert isinstance(event, platform_events.InteractionCallbackEvent) + assert event.raw_submission['action_ref'] == 0 + assert event.target_id == 'group-1' native['event']['template_card_event'] = {'button': {'key': 'lbi:callback-token:f:0:2'}} nested_event = interaction_event_from_native(native) - assert nested_event.data['field_ref'] == 0 - assert nested_event.data['option_ref'] == 2 + assert nested_event.raw_submission['field_ref'] == 0 + assert nested_event.raw_submission['option_ref'] == 2 @pytest.mark.asyncio @@ -669,8 +709,7 @@ async def test_managed_card_callback_does_not_start_business_event(): ) listener.assert_awaited_once() event = listener.await_args.args[0] - assert event.type == 'platform.specific' - assert event.action == 'interaction.submitted' + assert isinstance(event, platform_events.InteractionCallbackEvent) @pytest.mark.asyncio