feat: add prompt startup actions and shared vision config
This commit is contained in:
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from models import AssistantConfig, RuntimeTool
|
||||
from pipecat.frames.frames import (
|
||||
@@ -29,8 +29,8 @@ from services.brains.dify_llm import (
|
||||
)
|
||||
from services.brains.workflow_brain import WorkflowBrain
|
||||
from services.runtime_variables import prepare_dynamic_config
|
||||
from services.action_runtime import ActionError, ActionOutcome, ActionStatus
|
||||
from services.workflow.models import (
|
||||
ActionStatus,
|
||||
LLMRouteResult,
|
||||
RouteStatus,
|
||||
WorkflowStatus,
|
||||
@@ -246,6 +246,206 @@ class DifyLLMServiceTests(unittest.IsolatedAsyncioTestCase):
|
||||
|
||||
|
||||
class PromptBrainTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_preflight_runs_multiple_server_tools_in_order(self):
|
||||
tools = [
|
||||
RuntimeTool(
|
||||
id=f"preflight_{index}",
|
||||
name=f"预检动作 {index}",
|
||||
function_name=f"preflight_{index}",
|
||||
type="http",
|
||||
)
|
||||
for index in (1, 2)
|
||||
]
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
tools=tools,
|
||||
startup={
|
||||
"actions": [
|
||||
{
|
||||
"id": tool.id,
|
||||
"phase": "preflight",
|
||||
"tool_id": tool.id,
|
||||
"required": True,
|
||||
}
|
||||
for tool in tools
|
||||
]
|
||||
},
|
||||
)
|
||||
brain = build_brain(cfg)
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=noop_queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=FakeCallEnd(),
|
||||
),
|
||||
)
|
||||
brain._actions.execute = AsyncMock(
|
||||
side_effect=[
|
||||
ActionOutcome(
|
||||
invocation_id=f"act_{index}",
|
||||
status=ActionStatus.SUCCESS,
|
||||
duration_ms=index,
|
||||
)
|
||||
for index in (1, 2)
|
||||
]
|
||||
)
|
||||
|
||||
await brain.run_preflight()
|
||||
|
||||
self.assertEqual(
|
||||
[call.args[0].id for call in brain._actions.execute.await_args_list],
|
||||
["preflight_1", "preflight_2"],
|
||||
)
|
||||
|
||||
async def test_opening_actions_wait_for_confirmation_and_greeting(self):
|
||||
tools = [
|
||||
RuntimeTool(
|
||||
id=f"opening_{index}",
|
||||
name=f"开场动作 {index}",
|
||||
function_name="show_message" if index == 1 else "load_opening_data",
|
||||
type="client" if index == 1 else "http",
|
||||
)
|
||||
for index in (1, 2)
|
||||
]
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
tools=tools,
|
||||
startup={
|
||||
"execution_mode": "sequential",
|
||||
"actions": [
|
||||
{
|
||||
"id": f"opening_{index}",
|
||||
"phase": "opening",
|
||||
"tool_id": f"opening_{index}",
|
||||
"arguments": {},
|
||||
"required": True,
|
||||
}
|
||||
for index in (1, 2)
|
||||
],
|
||||
},
|
||||
)
|
||||
brain = build_brain(cfg)
|
||||
input_states = []
|
||||
queued = []
|
||||
|
||||
async def queue_frame(frame):
|
||||
queued.append(frame)
|
||||
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=FakeCallEnd(),
|
||||
set_input_enabled=input_states.append,
|
||||
),
|
||||
)
|
||||
brain._actions.execute = AsyncMock(
|
||||
side_effect=[
|
||||
ActionOutcome(
|
||||
invocation_id=f"act_{index}",
|
||||
status=ActionStatus.SUCCESS,
|
||||
duration_ms=index,
|
||||
)
|
||||
for index in (1, 2)
|
||||
]
|
||||
)
|
||||
|
||||
await brain.on_connected(greeting_pending=True)
|
||||
await brain.on_client_ready()
|
||||
|
||||
self.assertEqual(input_states, [False])
|
||||
called_tool_ids = [
|
||||
call.args[0].id for call in brain._actions.execute.await_args_list
|
||||
]
|
||||
self.assertEqual(called_tool_ids, ["opening_1", "opening_2"])
|
||||
self.assertEqual(
|
||||
len(
|
||||
[
|
||||
frame
|
||||
for frame in queued
|
||||
if isinstance(frame, OutputTransportMessageUrgentFrame)
|
||||
and frame.message.get("type") == "startup-action-result"
|
||||
]
|
||||
),
|
||||
2,
|
||||
)
|
||||
|
||||
await brain.on_greeting_finished()
|
||||
self.assertEqual(input_states, [False, True])
|
||||
|
||||
# Replayed client-ready must not execute startup actions twice.
|
||||
await brain.on_client_ready()
|
||||
self.assertEqual(brain._actions.execute.await_count, 2)
|
||||
|
||||
async def test_required_opening_failure_keeps_input_blocked_and_ends_call(self):
|
||||
tool = RuntimeTool(
|
||||
id="opening_message",
|
||||
name="开场确认",
|
||||
function_name="show_message",
|
||||
type="client",
|
||||
)
|
||||
cfg = AssistantConfig(
|
||||
type="prompt",
|
||||
tools=[tool],
|
||||
startup={
|
||||
"actions": [
|
||||
{
|
||||
"id": "opening_message",
|
||||
"phase": "opening",
|
||||
"tool_id": tool.id,
|
||||
"arguments": {},
|
||||
"required": True,
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
brain = build_brain(cfg)
|
||||
call_end = FakeCallEnd()
|
||||
input_states = []
|
||||
|
||||
async def queue_frame(_frame):
|
||||
pass
|
||||
|
||||
await brain.setup(
|
||||
cfg,
|
||||
BrainRuntime(
|
||||
context=LLMContext(messages=[]),
|
||||
llm=FakeLLM(),
|
||||
queue_frame=queue_frame,
|
||||
set_system_prompt=lambda _prompt: None,
|
||||
set_tools=lambda _tools: None,
|
||||
call_end=call_end,
|
||||
set_input_enabled=input_states.append,
|
||||
),
|
||||
)
|
||||
brain._actions.execute = AsyncMock(
|
||||
return_value=ActionOutcome(
|
||||
invocation_id="act_failed",
|
||||
status=ActionStatus.FAILURE,
|
||||
duration_ms=1,
|
||||
error=ActionError(
|
||||
code="tool_error",
|
||||
message="用户未确认",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
await brain.on_connected(greeting_pending=False)
|
||||
await brain.on_client_ready()
|
||||
|
||||
self.assertEqual(input_states, [False])
|
||||
self.assertTrue(call_end.ending)
|
||||
self.assertTrue(call_end.finished)
|
||||
self.assertEqual(call_end.reason, "startup_action_failed")
|
||||
|
||||
async def test_realtime_prompt_brain_renders_dynamic_variables(self):
|
||||
cfg = prepare_dynamic_config(
|
||||
AssistantConfig(
|
||||
|
||||
106
backend/tests/test_startup_actions.py
Normal file
106
backend/tests/test_startup_actions.py
Normal file
@@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from routes.assistants import _validate_startup_actions
|
||||
from schemas import AssistantUpsert
|
||||
|
||||
|
||||
def startup_body(*, phase: str = "opening") -> AssistantUpsert:
|
||||
return AssistantUpsert(
|
||||
name="启动动作测试",
|
||||
type="prompt",
|
||||
runtimeMode="pipeline",
|
||||
toolIds=["tool_message"],
|
||||
startup={
|
||||
"executionMode": "sequential",
|
||||
"actions": [
|
||||
{
|
||||
"id": "opening_message",
|
||||
"phase": phase,
|
||||
"toolId": "tool_message",
|
||||
"arguments": {
|
||||
"title": "重要提示",
|
||||
"message": "请确认已阅读。",
|
||||
"actions": [
|
||||
{
|
||||
"id": "confirmed",
|
||||
"label": "确认",
|
||||
"style": "primary",
|
||||
}
|
||||
],
|
||||
"dismissible": False,
|
||||
},
|
||||
"required": True,
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, tool):
|
||||
self.tool = tool
|
||||
|
||||
async def get(self, _model, tool_id):
|
||||
return self.tool if tool_id == self.tool.id else None
|
||||
|
||||
|
||||
class StartupActionValidationTests(unittest.IsolatedAsyncioTestCase):
|
||||
def test_realtime_rejects_startup_actions(self):
|
||||
with self.assertRaisesRegex(ValueError, "Realtime"):
|
||||
AssistantUpsert(
|
||||
name="Realtime 启动动作",
|
||||
type="prompt",
|
||||
runtimeMode="realtime",
|
||||
toolIds=["tool_message"],
|
||||
startup=startup_body().startup,
|
||||
)
|
||||
|
||||
async def test_show_message_requires_session_wait(self):
|
||||
tool = SimpleNamespace(
|
||||
id="tool_message",
|
||||
name="重要提示",
|
||||
function_name="show_message",
|
||||
type="client",
|
||||
status="active",
|
||||
definition={
|
||||
"config": {
|
||||
"wait_for_response": True,
|
||||
"response_wait_mode": "timeout",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(HTTPException, "会话内等待"):
|
||||
await _validate_startup_actions(FakeSession(tool), startup_body())
|
||||
|
||||
tool.definition["config"]["response_wait_mode"] = "session"
|
||||
await _validate_startup_actions(FakeSession(tool), startup_body())
|
||||
|
||||
async def test_preflight_rejects_client_tools(self):
|
||||
tool = SimpleNamespace(
|
||||
id="tool_message",
|
||||
name="重要提示",
|
||||
function_name="show_message",
|
||||
type="client",
|
||||
status="active",
|
||||
definition={
|
||||
"config": {
|
||||
"wait_for_response": True,
|
||||
"response_wait_mode": "session",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(HTTPException, "preflight"):
|
||||
await _validate_startup_actions(
|
||||
FakeSession(tool),
|
||||
startup_body(phase="preflight"),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user