Studio: guard durable research actions
This commit is contained in:
parent
633211bd1d
commit
771d8373b4
5 changed files with 162 additions and 34 deletions
|
|
@ -17,6 +17,7 @@ from typing import Any, AsyncIterator
|
|||
import httpx
|
||||
|
||||
from auth import storage as auth_storage
|
||||
from core.inference.message_content import content_to_text
|
||||
from core.inference.tool_loop_controller import is_tool_error, strip_result_for_model
|
||||
from core.inference.tools import RAG_SOURCES_SENTINEL, execute_tool
|
||||
from core.inference.web_access_policy import check_url_access, website_policy_prompt
|
||||
|
|
@ -151,16 +152,7 @@ def _safe_error(exc: BaseException) -> str:
|
|||
|
||||
|
||||
def _extract_text(message: dict) -> str:
|
||||
content = message.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(
|
||||
str(part.get("text") or "")
|
||||
for part in content
|
||||
if isinstance(part, dict) and part.get("type") == "text"
|
||||
).strip()
|
||||
return ""
|
||||
return content_to_text(message.get("content")).strip()
|
||||
|
||||
|
||||
def _research_question_context(thread_id: str, user_message_id: str) -> tuple[str, str]:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from fastapi.responses import StreamingResponse
|
|||
from pydantic import AliasChoices, BaseModel, ConfigDict, Field
|
||||
|
||||
from auth.authentication import get_current_subject
|
||||
from core.inference.message_content import content_to_text
|
||||
from core.inference.web_access_policy import normalize_website_policy
|
||||
from storage import research_runs_db as db
|
||||
from storage.studio_db import get_chat_message, get_chat_thread, upsert_chat_message
|
||||
|
|
@ -239,6 +240,11 @@ async def create_research_run(
|
|||
raise HTTPException(
|
||||
status_code = 400, detail = "userMessageId must identify a user message in the thread"
|
||||
)
|
||||
if not content_to_text(user_message.get("content")).strip():
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "Deep research requires a user message with non-empty text",
|
||||
)
|
||||
if db.has_thread_claim(payload.threadId):
|
||||
raise HTTPException(
|
||||
status_code = 409,
|
||||
|
|
|
|||
|
|
@ -1144,6 +1144,87 @@ def test_create_without_assistant_id_does_not_eagerly_create_message(research_ho
|
|||
assert studio_db.list_chat_messages("thread-1") == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content", "attachments"),
|
||||
[
|
||||
([{"type": "text", "text": " \n\t"}], None),
|
||||
(
|
||||
[{"type": "file", "filename": "notes.pdf"}],
|
||||
[{"name": "notes.pdf", "contentType": "application/pdf"}],
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_route_rejects_textless_research_before_claim(research_home, content, attachments):
|
||||
from fastapi import HTTPException
|
||||
from routes.research_runs import CreateResearchRun, create_research_run
|
||||
|
||||
studio_db.upsert_chat_message(
|
||||
{
|
||||
"id": "user-1",
|
||||
"threadId": "thread-1",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"attachments": attachments,
|
||||
"createdAt": 2,
|
||||
}
|
||||
)
|
||||
request = SimpleNamespace(app = SimpleNamespace(state = SimpleNamespace()))
|
||||
|
||||
with pytest.raises(HTTPException, match = "non-empty text") as caught:
|
||||
asyncio.run(
|
||||
create_research_run(
|
||||
CreateResearchRun(
|
||||
threadId = "thread-1",
|
||||
userMessageId = "user-1",
|
||||
inferenceRequest = {"model": "local-model"},
|
||||
),
|
||||
request,
|
||||
current_subject = "alice",
|
||||
)
|
||||
)
|
||||
|
||||
assert caught.value.status_code == 400
|
||||
assert research_db.has_thread_claim("thread-1") is False
|
||||
assert research_db.get_run("run-1") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
["Research this question"],
|
||||
[{"text": "Research this question"}],
|
||||
],
|
||||
)
|
||||
def test_route_accepts_canonical_text_content_shapes(research_home, content):
|
||||
from core import research_runs as worker
|
||||
from routes.research_runs import CreateResearchRun, create_research_run
|
||||
|
||||
studio_db.upsert_chat_message(
|
||||
{
|
||||
"id": "user-1",
|
||||
"threadId": "thread-1",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"createdAt": 2,
|
||||
}
|
||||
)
|
||||
run = asyncio.run(
|
||||
create_research_run(
|
||||
CreateResearchRun(
|
||||
threadId = "thread-1",
|
||||
userMessageId = "user-1",
|
||||
inferenceRequest = {"model": "local-model"},
|
||||
),
|
||||
SimpleNamespace(app = SimpleNamespace(state = SimpleNamespace())),
|
||||
current_subject = "alice",
|
||||
)
|
||||
)
|
||||
|
||||
assert run["status"] == "planning"
|
||||
assert research_db.has_thread_claim("thread-1") is True
|
||||
assert worker._extract_text({"content": content}) == "Research this question"
|
||||
|
||||
|
||||
def test_route_rejects_overlapping_active_run_for_thread(research_home):
|
||||
from fastapi import HTTPException
|
||||
from routes.research_runs import CreateResearchRun, create_research_run
|
||||
|
|
|
|||
|
|
@ -3614,20 +3614,23 @@ const ComposerRightControls: FC<{
|
|||
};
|
||||
|
||||
const MessageError: FC = () => {
|
||||
const research = useResearchMessageState();
|
||||
return (
|
||||
<MessagePrimitive.Error>
|
||||
<ErrorPrimitive.Root className="aui-message-error-root mt-2 flex flex-wrap items-center gap-x-3 gap-y-2 rounded-md bg-destructive/10 p-3 text-destructive text-sm dark:bg-destructive/5 dark:text-red-200">
|
||||
<ErrorPrimitive.Message className="aui-message-error-message line-clamp-2 min-w-0 flex-1" />
|
||||
{/* Recovery path for interrupted/failed turns: regenerate in place. */}
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="aui-message-error-retry inline-flex shrink-0 items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</ActionBarPrimitive.Reload>
|
||||
{!research.runId && (
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<button
|
||||
type="button"
|
||||
className="aui-message-error-retry inline-flex shrink-0 items-center gap-1.5 rounded-md border border-destructive/40 px-2.5 py-1 text-xs font-medium transition-colors hover:bg-destructive/15"
|
||||
>
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-3.5" />
|
||||
Retry
|
||||
</button>
|
||||
</ActionBarPrimitive.Reload>
|
||||
)}
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
);
|
||||
|
|
@ -3975,10 +3978,50 @@ const ForkMessageButton: FC = () => {
|
|||
);
|
||||
};
|
||||
|
||||
const TERMINAL_RESEARCH_MESSAGE_STATUSES = new Set([
|
||||
"cancelled",
|
||||
"completed",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
const useResearchMessageState = () => {
|
||||
const metadata = useAuiState(({ message }) =>
|
||||
(
|
||||
message.metadata as
|
||||
| {
|
||||
custom?: {
|
||||
researchRunId?: unknown;
|
||||
researchStatus?: unknown;
|
||||
researchRun?: { id?: unknown; status?: unknown };
|
||||
};
|
||||
}
|
||||
| undefined
|
||||
)?.custom,
|
||||
);
|
||||
const metadataRunId = metadata?.researchRunId ?? metadata?.researchRun?.id;
|
||||
const runId = typeof metadataRunId === "string" ? metadataRunId : null;
|
||||
const followedStatus = useResearchRunStore((state) =>
|
||||
runId ? state.sessions[runId]?.run.status : undefined,
|
||||
);
|
||||
const metadataStatus = metadata?.researchStatus ?? metadata?.researchRun?.status;
|
||||
return {
|
||||
runId,
|
||||
status:
|
||||
followedStatus ??
|
||||
(typeof metadataStatus === "string" ? metadataStatus : null),
|
||||
};
|
||||
};
|
||||
|
||||
const DeleteMessageButton: FC = () => {
|
||||
const aui = useAui();
|
||||
const messageId = useAuiState(({ message }) => message.id);
|
||||
const isRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
const research = useResearchMessageState();
|
||||
const isActiveResearchMessage = Boolean(
|
||||
research.runId &&
|
||||
(!research.status ||
|
||||
!TERMINAL_RESEARCH_MESSAGE_STATUSES.has(research.status)),
|
||||
);
|
||||
|
||||
const handleDelete = async () => {
|
||||
const thread = aui.thread();
|
||||
|
|
@ -4023,6 +4066,10 @@ const DeleteMessageButton: FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
if (isActiveResearchMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipIconButton
|
||||
tooltip="Delete message"
|
||||
|
|
@ -4071,18 +4118,11 @@ const CopyButton: FC = () => {
|
|||
|
||||
const EditAssistantMessageButton: FC = () => {
|
||||
const messageId = useAuiState(({ message }) => message.id);
|
||||
const isResearchMessage = useAuiState(({ message }) => {
|
||||
const custom = (
|
||||
message.metadata as
|
||||
| { custom?: { researchRunId?: unknown } }
|
||||
| undefined
|
||||
)?.custom;
|
||||
return typeof custom?.researchRunId === "string";
|
||||
});
|
||||
const research = useResearchMessageState();
|
||||
const isRunning = useAuiState(({ thread }) => thread.isRunning);
|
||||
const setEditingId = useChatRuntimeStore((s) => s.setEditingMessageId);
|
||||
|
||||
if (isResearchMessage) return null;
|
||||
if (research.runId) return null;
|
||||
|
||||
return (
|
||||
<TooltipIconButton
|
||||
|
|
@ -4101,6 +4141,7 @@ const EditAssistantMessageButton: FC = () => {
|
|||
|
||||
const AssistantActionBar: FC = () => {
|
||||
const { forkMessage, forkDisabled } = useForkMessageAction();
|
||||
const research = useResearchMessageState();
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const ttsEnabled = useVoiceSettingsStore((s) => s.ttsEnabled);
|
||||
// hideWhenRunning is thread-level, so a new run would hide this bar and its
|
||||
|
|
@ -4115,11 +4156,13 @@ const AssistantActionBar: FC = () => {
|
|||
>
|
||||
<CopyButton />
|
||||
<EditAssistantMessageButton />
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
{!research.runId && (
|
||||
<ActionBarPrimitive.Reload asChild={true}>
|
||||
<TooltipIconButton tooltip="Refresh">
|
||||
<RefreshCwIcon strokeWidth={1.75} className="size-icon" />
|
||||
</TooltipIconButton>
|
||||
</ActionBarPrimitive.Reload>
|
||||
)}
|
||||
<ForkCountBadge />
|
||||
<DeleteMessageButton />
|
||||
{ttsEnabled && (
|
||||
|
|
|
|||
|
|
@ -94,7 +94,13 @@ def test_research_presentation_is_integrated() -> None:
|
|||
research_gate = thread.split("const researchDisabled =", 1)[1].split(";", 1)[0]
|
||||
assert "!modelLoaded" not in research_gate
|
||||
assert "<ResearchMessage />" in thread
|
||||
assert "if (isResearchMessage) return null" in thread
|
||||
assert "if (research.runId) return null" in thread
|
||||
assert "!research.runId &&" in thread
|
||||
assert "if (isActiveResearchMessage)" in thread
|
||||
message_error = thread.split("const MessageError: FC = () =>", 1)[1].split(
|
||||
"const GeneratingIndicator:", 1
|
||||
)[0]
|
||||
assert "!research.runId &&" in message_error
|
||||
assert "ResearchActivityPanel" in page
|
||||
assert "ResearchActivitySheet" in page
|
||||
assert "ResearchActivityPanel" in chat_index
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue