chore: remaining pre-existing changes (storage, stream processor, tests, run.sh)
This commit is contained in:
@@ -12,6 +12,7 @@ from pydantic import BaseModel, Field
|
|||||||
class FileCategory(str, Enum):
|
class FileCategory(str, Enum):
|
||||||
BACKUP = "backups"
|
BACKUP = "backups"
|
||||||
REPOSITORY = "repositories"
|
REPOSITORY = "repositories"
|
||||||
|
CHAT_UPLOADS = "chat_uploads"
|
||||||
# #endregion FileCategory
|
# #endregion FileCategory
|
||||||
|
|
||||||
# #region StorageConfig [C:1] [TYPE Class]
|
# #region StorageConfig [C:1] [TYPE Class]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import sys
|
|||||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import asyncio
|
||||||
from unittest.mock import AsyncMock, MagicMock, patch
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -77,6 +78,120 @@ class TestExtractUserId:
|
|||||||
# #endregion test_extract_user_id
|
# #endregion test_extract_user_id
|
||||||
|
|
||||||
|
|
||||||
|
# #region test_confirmation_metadata [C:2] [TYPE Function]
|
||||||
|
# @BRIEF Test backend HITL confirmation contract exposed to the frontend.
|
||||||
|
class TestConfirmationMetadata:
|
||||||
|
def test_fast_confirmation_tool_detects_no_arg_read_intent(self):
|
||||||
|
from src.agent.app import _fast_confirmation_tool
|
||||||
|
|
||||||
|
assert _fast_confirmation_tool("Какие есть окружения?") == "list_environments"
|
||||||
|
assert _fast_confirmation_tool("Покажи maintenance") == "list_maintenance_events"
|
||||||
|
assert _fast_confirmation_tool("Запусти миграцию") is None
|
||||||
|
|
||||||
|
def test_extracts_tool_call_and_read_contract(self):
|
||||||
|
from src.agent.app import _confirmation_metadata
|
||||||
|
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.tool_calls = [{"name": "list_environments", "args": {"env_id": "prod"}}]
|
||||||
|
state = MagicMock()
|
||||||
|
state.values = {"messages": [msg]}
|
||||||
|
state.next = ("tools",)
|
||||||
|
|
||||||
|
meta = _confirmation_metadata("conv-1", state, "Какие есть окружения?")
|
||||||
|
|
||||||
|
assert meta["type"] == "confirm_required"
|
||||||
|
assert meta["thread_id"] == "conv-1"
|
||||||
|
assert meta["prompt"] == "Разрешить чтение данных?"
|
||||||
|
assert meta["tool_name"] == "list_environments"
|
||||||
|
assert meta["tool_args"] == {"env_id": "prod"}
|
||||||
|
assert meta["risk"] == "read"
|
||||||
|
assert meta["risk_level"] == "safe"
|
||||||
|
assert meta["requires_confirmation"] is True
|
||||||
|
assert meta["intent"]["operation"] == "list_environments"
|
||||||
|
|
||||||
|
def test_infers_tool_from_text_when_state_only_has_graph_node(self):
|
||||||
|
from src.agent.app import _confirmation_metadata
|
||||||
|
|
||||||
|
state = MagicMock()
|
||||||
|
state.values = {"messages": []}
|
||||||
|
state.next = ("tools",)
|
||||||
|
|
||||||
|
meta = _confirmation_metadata("conv-2", state, "Покажи окружения")
|
||||||
|
|
||||||
|
assert meta["tool_name"] == "list_environments"
|
||||||
|
assert meta["risk"] == "read"
|
||||||
|
assert meta["risk_level"] == "safe"
|
||||||
|
assert meta["prompt"] == "Разрешить чтение данных?"
|
||||||
|
|
||||||
|
def test_write_contract_for_guarded_tool(self):
|
||||||
|
from src.agent.app import _confirmation_metadata
|
||||||
|
|
||||||
|
msg = MagicMock()
|
||||||
|
msg.tool_calls = [{
|
||||||
|
"name": "start_maintenance",
|
||||||
|
"args": {"environment_id": "prod", "tables": ["sales"]},
|
||||||
|
}]
|
||||||
|
state = MagicMock()
|
||||||
|
state.values = {"messages": [msg]}
|
||||||
|
state.next = ("tools",)
|
||||||
|
|
||||||
|
meta = _confirmation_metadata("conv-3", state, "Запусти maintenance")
|
||||||
|
|
||||||
|
assert meta["tool_name"] == "start_maintenance"
|
||||||
|
assert meta["risk"] == "write"
|
||||||
|
assert meta["risk_level"] == "guarded"
|
||||||
|
assert meta["prompt"] == "Подтвердить изменение данных?"
|
||||||
|
|
||||||
|
def test_unknown_contract_does_not_expose_graph_node_as_tool(self):
|
||||||
|
from src.agent.app import _confirmation_metadata
|
||||||
|
|
||||||
|
state = MagicMock()
|
||||||
|
state.values = {"messages": []}
|
||||||
|
state.next = ("tools",)
|
||||||
|
|
||||||
|
meta = _confirmation_metadata("conv-4", state, "Непонятное действие")
|
||||||
|
|
||||||
|
assert meta["tool_name"] == "unknown_action"
|
||||||
|
assert meta["risk"] == "unknown"
|
||||||
|
assert meta["risk_level"] == "unknown"
|
||||||
|
assert meta["prompt"] == "Подтвердите действие"
|
||||||
|
|
||||||
|
def test_fast_resume_deny_closes_without_langgraph(self):
|
||||||
|
from src.agent.app import _handle_resume, _pending_confirmations
|
||||||
|
|
||||||
|
_pending_confirmations["conv-fast-deny"] = {
|
||||||
|
"tool_name": "list_environments",
|
||||||
|
"tool_args": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def collect():
|
||||||
|
return [chunk async for chunk in _handle_resume("conv-fast-deny", "deny")]
|
||||||
|
|
||||||
|
chunks = asyncio.run(collect())
|
||||||
|
data = json.loads(chunks[0])
|
||||||
|
assert data["metadata"] == {"type": "confirm_resolved", "result": "denied"}
|
||||||
|
|
||||||
|
def test_fast_resume_confirm_executes_tool_directly(self):
|
||||||
|
from src.agent.app import _handle_resume, _pending_confirmations
|
||||||
|
|
||||||
|
tool = MagicMock()
|
||||||
|
tool.ainvoke = AsyncMock(return_value='[{"id":"ss-dev"}]')
|
||||||
|
_pending_confirmations["conv-fast-confirm"] = {
|
||||||
|
"tool_name": "list_environments",
|
||||||
|
"tool_args": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
async def collect():
|
||||||
|
with patch("src.agent.app._find_tool", return_value=tool):
|
||||||
|
return [chunk async for chunk in _handle_resume("conv-fast-confirm", "confirm")]
|
||||||
|
|
||||||
|
chunks = asyncio.run(collect())
|
||||||
|
metadata_types = [json.loads(chunk)["metadata"]["type"] for chunk in chunks]
|
||||||
|
assert metadata_types == ["confirm_resolved", "tool_start", "tool_end", "stream_token"]
|
||||||
|
assert json.loads(chunks[-1])["content"] == '[{"id":"ss-dev"}]'
|
||||||
|
# #endregion test_confirmation_metadata
|
||||||
|
|
||||||
|
|
||||||
# #region test_agent_handler [C:2] [TYPE Function]
|
# #region test_agent_handler [C:2] [TYPE Function]
|
||||||
# @BRIEF Test agent_handler for various scenarios.
|
# @BRIEF Test agent_handler for various scenarios.
|
||||||
class TestAgentHandler:
|
class TestAgentHandler:
|
||||||
|
|||||||
@@ -267,6 +267,32 @@ async def test_list_environments_calls_correct_url():
|
|||||||
args, kwargs = call_args
|
args, kwargs = call_args
|
||||||
url = args[0] if args else kwargs.get("url", "")
|
url = args[0] if args else kwargs.get("url", "")
|
||||||
assert "api/settings/environments" in url
|
assert "api/settings/environments" in url
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_list_environments_redacts_sensitive_fields():
|
||||||
|
"""list_environments must not expose backend secrets to chat output."""
|
||||||
|
from src.agent.context import set_service_jwt, set_user_jwt
|
||||||
|
from src.agent.tools import list_environments
|
||||||
|
|
||||||
|
set_user_jwt("jwt")
|
||||||
|
set_service_jwt("svc-jwt")
|
||||||
|
|
||||||
|
with patch("httpx.AsyncClient") as mock_client:
|
||||||
|
mock_instance = AsyncMock()
|
||||||
|
mock_client.return_value.__aenter__.return_value = mock_instance
|
||||||
|
mock_instance.get.return_value.status_code = 200
|
||||||
|
mock_instance.get.return_value.text = (
|
||||||
|
'[{"id":"prod","password":"secret-pass","api_key":"secret-key",'
|
||||||
|
'"nested":{"token":"secret-token"},"name":"ss-prod"}]'
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await list_environments.ainvoke({})
|
||||||
|
|
||||||
|
assert "secret-pass" not in result
|
||||||
|
assert "secret-key" not in result
|
||||||
|
assert "secret-token" not in result
|
||||||
|
assert result.count("[redacted]") == 3
|
||||||
# #endregion TestAgentChat.Tools.ListEnvironments
|
# #endregion TestAgentChat.Tools.ListEnvironments
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -414,6 +414,9 @@
|
|||||||
class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block"
|
class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
id="global-search"
|
||||||
|
name="global_search"
|
||||||
|
aria-label={$t.common.search}
|
||||||
type="text"
|
type="text"
|
||||||
class="w-full px-4 py-2 bg-surface-muted rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
class="w-full px-4 py-2 bg-surface-muted rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-ring transition-all
|
||||||
{isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
|
{isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
|
||||||
@@ -470,6 +473,8 @@
|
|||||||
{#if globalEnvironments.length > 0}
|
{#if globalEnvironments.length > 0}
|
||||||
<div class="hidden lg:flex items-center gap-2">
|
<div class="hidden lg:flex items-center gap-2">
|
||||||
<select
|
<select
|
||||||
|
id="global-environment-select"
|
||||||
|
name="global_environment"
|
||||||
class="h-9 rounded-lg border px-3 text-sm font-medium focus:outline-none focus:ring-2
|
class="h-9 rounded-lg border px-3 text-sm font-medium focus:outline-none focus:ring-2
|
||||||
{isProdContext
|
{isProdContext
|
||||||
? 'border-destructive-ring bg-destructive-light text-destructive focus:ring-destructive-ring'
|
? 'border-destructive-ring bg-destructive-light text-destructive focus:ring-destructive-ring'
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export interface StreamProcessorHost {
|
|||||||
pendingToolArgs: Record<string, unknown>;
|
pendingToolArgs: Record<string, unknown>;
|
||||||
pendingConfirmationRisk: "read" | "write" | "unknown" | null;
|
pendingConfirmationRisk: "read" | "write" | "unknown" | null;
|
||||||
pendingRiskLevel: string | null;
|
pendingRiskLevel: string | null;
|
||||||
|
/** Populated by file_uploaded metadata — attached to user message on stream end. */
|
||||||
|
_pendingAttachment: { file_name: string; file_path: string; file_size: number } | null;
|
||||||
captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void;
|
captureConversationId(meta: StreamMetadata | undefined, convIdFromEvent: string): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +209,16 @@ export class StreamProcessor {
|
|||||||
this.host.error = meta.detail ?? meta.code ?? "Unknown error";
|
this.host.error = meta.detail ?? meta.code ?? "Unknown error";
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "file_uploaded":
|
||||||
|
if (meta.file_name && meta.file_path) {
|
||||||
|
this.host._pendingAttachment = {
|
||||||
|
file_name: meta.file_name,
|
||||||
|
file_path: meta.file_path,
|
||||||
|
file_size: meta.file_size ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
log("AgentChat.StreamProcessor", "EXPLORE", "Unknown metadata type", { type: meta.type }, "Unhandled metadata type " + meta.type);
|
log("AgentChat.StreamProcessor", "EXPLORE", "Unknown metadata type", { type: meta.type }, "Unhandled metadata type " + meta.type);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ export type ConnectionState =
|
|||||||
|
|
||||||
export interface StreamMetadata {
|
export interface StreamMetadata {
|
||||||
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
|
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
|
||||||
| "confirm_required" | "confirm_resolved" | "error";
|
| "confirm_required" | "confirm_resolved" | "error"
|
||||||
|
| "file_uploaded";
|
||||||
token?: string;
|
token?: string;
|
||||||
tool?: string;
|
tool?: string;
|
||||||
input?: Record<string, unknown>;
|
input?: Record<string, unknown>;
|
||||||
@@ -30,6 +31,22 @@ export interface StreamMetadata {
|
|||||||
result?: "confirmed" | "denied";
|
result?: "confirmed" | "denied";
|
||||||
code?: string;
|
code?: string;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
/** Tool name being requested for confirmation (HITL). */
|
||||||
|
tool_name?: string;
|
||||||
|
/** Tool arguments for the pending confirmation call. */
|
||||||
|
tool_args?: Record<string, unknown>;
|
||||||
|
/** Coarse UI risk category for the pending confirmation. */
|
||||||
|
risk?: "read" | "write" | "unknown" | string;
|
||||||
|
/** Backend operation risk level from the agent contract. */
|
||||||
|
risk_level?: "safe" | "guarded" | "dangerous" | "unknown" | string;
|
||||||
|
requires_confirmation?: boolean;
|
||||||
|
intent?: Record<string, unknown>;
|
||||||
|
/** file_uploaded: name of uploaded file. */
|
||||||
|
file_name?: string;
|
||||||
|
/** file_uploaded: relative storage path for download. */
|
||||||
|
file_path?: string;
|
||||||
|
/** file_uploaded: file size in bytes. */
|
||||||
|
file_size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCall {
|
export interface ToolCall {
|
||||||
@@ -50,6 +67,12 @@ export interface AgentMessage {
|
|||||||
task_id?: string;
|
task_id?: string;
|
||||||
toolCalls: ToolCall[];
|
toolCalls: ToolCall[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
/** Attached file info (populated from file_uploaded metadata). */
|
||||||
|
attachment?: {
|
||||||
|
file_name: string;
|
||||||
|
file_path: string;
|
||||||
|
file_size: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Conversation {
|
export interface Conversation {
|
||||||
|
|||||||
@@ -139,12 +139,26 @@ describe("AgentChatModel — State Machine", () => {
|
|||||||
await model.resumeConfirm("confirm");
|
await model.resumeConfirm("confirm");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resumeConfirm returns early when pendingThreadId is null", async () => {
|
it("resumeConfirm deny closes pending confirmation locally when pendingThreadId is null", async () => {
|
||||||
model.streamingState = "awaiting_confirmation";
|
model.streamingState = "awaiting_confirmation";
|
||||||
|
model.currentConversationId = "local-deny";
|
||||||
|
model.pendingThreadId = null;
|
||||||
|
const submitBefore = model._client?.submit;
|
||||||
|
await model.resumeConfirm("deny");
|
||||||
|
expect(model.streamingState).toBe("idle");
|
||||||
|
expect(model._client?.submit).toBe(submitBefore);
|
||||||
|
expect(model.messages.at(-1)?.text).toContain("Операция отменена");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resumeConfirm confirm without pendingThreadId exits guardrail with error", async () => {
|
||||||
|
model.streamingState = "awaiting_confirmation";
|
||||||
|
model.currentConversationId = "missing-thread";
|
||||||
model.pendingThreadId = null;
|
model.pendingThreadId = null;
|
||||||
const submitBefore = model._client?.submit;
|
const submitBefore = model._client?.submit;
|
||||||
await model.resumeConfirm("confirm");
|
await model.resumeConfirm("confirm");
|
||||||
expect(model.streamingState).toBe("awaiting_confirmation");
|
expect(model.streamingState).toBe("idle");
|
||||||
|
expect(model._client?.submit).toBe(submitBefore);
|
||||||
|
expect(model.error).toContain("отсутствует checkpoint");
|
||||||
});
|
});
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -227,6 +241,96 @@ describe("AgentChatModel — Derived State", () => {
|
|||||||
model["_messageQueue"] = [{ text: "a" }, { text: "b" }] as any;
|
model["_messageQueue"] = [{ text: "a" }, { text: "b" }] as any;
|
||||||
expect(model.queuePosition).toBe(2);
|
expect(model.queuePosition).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("debug panel is closed by default and toggles through model actions", () => {
|
||||||
|
expect(model.isDebugPanelOpen).toBe(false);
|
||||||
|
model.toggleDebugPanel();
|
||||||
|
expect(model.isDebugPanelOpen).toBe(true);
|
||||||
|
model.setDebugPanelOpen(false);
|
||||||
|
expect(model.isDebugPanelOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conversation sidebar is closed by default and toggles through model actions", () => {
|
||||||
|
expect(model.isConversationSidebarOpen).toBe(false);
|
||||||
|
model.toggleConversationSidebar();
|
||||||
|
expect(model.isConversationSidebarOpen).toBe(true);
|
||||||
|
model.setConversationSidebarOpen(false);
|
||||||
|
expect(model.isConversationSidebarOpen).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("conversationListItems sanitizes pre-fetched prompt noise for views", () => {
|
||||||
|
model.conversations = [{
|
||||||
|
id: "c1",
|
||||||
|
title: "Покажи доступные дашборды [PRE-FETCHED DATA — use this directly, do NOT call tools] Available dashboards",
|
||||||
|
updated_at: "",
|
||||||
|
message_count: 2,
|
||||||
|
}];
|
||||||
|
expect(model.conversations[0].title).toContain("PRE-FETCHED DATA");
|
||||||
|
expect(model.conversationListItems[0].title).toBe("Покажи доступные дашборды");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("currentConversationTitle uses sanitized conversation title", () => {
|
||||||
|
model.currentConversationId = "c1";
|
||||||
|
model.conversations = [{
|
||||||
|
id: "c1",
|
||||||
|
title: "Что конкретно с дашбордом FCC? [PRE-FETCHED DATA — use this directly]",
|
||||||
|
updated_at: "",
|
||||||
|
message_count: 3,
|
||||||
|
}];
|
||||||
|
expect(model.currentConversationTitle).toBe("Что конкретно с дашбордом FCC?");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("agentPhase reflects connection, streaming, tool, confirmation and error states", () => {
|
||||||
|
expect(model.agentPhase).toBe("ready");
|
||||||
|
model.streamingState = "streaming";
|
||||||
|
expect(model.agentPhase).toBe("thinking");
|
||||||
|
model.activeToolCalls = [{ tool: "search", input: {}, status: "executing" }];
|
||||||
|
expect(model.agentPhase).toBe("tooling");
|
||||||
|
model.streamingState = "awaiting_confirmation";
|
||||||
|
expect(model.agentPhase).toBe("confirming");
|
||||||
|
model.streamingState = "error";
|
||||||
|
expect(model.agentPhase).toBe("failed");
|
||||||
|
model.connectionState = "disconnected";
|
||||||
|
expect(model.agentPhase).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("quickActions expose stable operational entry points for the view", () => {
|
||||||
|
expect(model.quickActions.map((action) => action.id)).toEqual([
|
||||||
|
"dashboards",
|
||||||
|
"migration",
|
||||||
|
"tasks",
|
||||||
|
"health",
|
||||||
|
]);
|
||||||
|
expect(model.quickActions.every((action) => action.prompt.length > 0)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirmation copy distinguishes read-only tools from mutating tools", () => {
|
||||||
|
model.pendingThreadId = "thread-1";
|
||||||
|
model.pendingToolName = "list_environments";
|
||||||
|
expect(model.confirmationRisk).toBe("read");
|
||||||
|
expect(model.confirmationTitleKey).toBe("confirmation_read_title");
|
||||||
|
expect(model.confirmationDescriptionKey).toBe("confirmation_read_description");
|
||||||
|
expect(model.pendingToolLabel).toBe("List Environments");
|
||||||
|
|
||||||
|
model.pendingToolName = "execute_migration";
|
||||||
|
expect(model.confirmationRisk).toBe("write");
|
||||||
|
expect(model.confirmationTitleKey).toBe("confirmation_write_title");
|
||||||
|
expect(model.confirmationDescriptionKey).toBe("confirmation_scope_fallback");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirmation copy flags missing checkpoint before risk copy", () => {
|
||||||
|
model.pendingThreadId = null;
|
||||||
|
model.pendingToolName = "list_environments";
|
||||||
|
expect(model.confirmationRisk).toBe("read");
|
||||||
|
expect(model.confirmationDescriptionKey).toBe("confirmation_missing_checkpoint");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("confirmationMessageOverride ignores generic backend titles but preserves specific copy", () => {
|
||||||
|
model.confirmationMessage = "Подтвердить операцию?";
|
||||||
|
expect(model.confirmationMessageOverride).toBe("");
|
||||||
|
model.confirmationMessage = "Разрешить выполнение deploy_dashboard?";
|
||||||
|
expect(model.confirmationMessageOverride).toBe("Разрешить выполнение deploy_dashboard?");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// #endregion
|
// #endregion
|
||||||
|
|
||||||
@@ -278,9 +382,29 @@ describe("AgentChatModel — Metadata Handling", () => {
|
|||||||
|
|
||||||
it("handles confirm_required metadata", () => {
|
it("handles confirm_required metadata", () => {
|
||||||
model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "",
|
model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "c1", role: "assistant", text: "", toolCalls: [], created_at: "",
|
||||||
metadata: { type: "confirm_required", prompt: "Deploy?", thread_id: "thread-1" } });
|
metadata: {
|
||||||
|
type: "confirm_required",
|
||||||
|
prompt: "Deploy?",
|
||||||
|
thread_id: "thread-1",
|
||||||
|
tool_name: "list_environments",
|
||||||
|
tool_args: { env_id: "prod" },
|
||||||
|
risk: "write",
|
||||||
|
risk_level: "guarded",
|
||||||
|
} });
|
||||||
expect(model.streamingState).toBe("awaiting_confirmation");
|
expect(model.streamingState).toBe("awaiting_confirmation");
|
||||||
expect(model.pendingThreadId).toBe("thread-1");
|
expect(model.pendingThreadId).toBe("thread-1");
|
||||||
|
expect(model.pendingToolName).toBe("list_environments");
|
||||||
|
expect(model.pendingToolArgs).toEqual({ env_id: "prod" });
|
||||||
|
expect(model.confirmationRisk).toBe("write");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to current conversation id when confirm_required lacks thread_id", () => {
|
||||||
|
model.currentConversationId = "conv-fallback";
|
||||||
|
model["streamProcessor"]["_onStreamData"]({ id: "1", conversation_id: "conv-fallback", role: "assistant", text: "", toolCalls: [], created_at: "",
|
||||||
|
metadata: { type: "confirm_required", prompt: "Подтвердить", tool_name: "list_environments" } });
|
||||||
|
expect(model.streamingState).toBe("awaiting_confirmation");
|
||||||
|
expect(model.pendingThreadId).toBe("conv-fallback");
|
||||||
|
expect(model.confirmationDescriptionKey).not.toBe("confirmation_missing_checkpoint");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles confirm_resolved metadata", () => {
|
it("handles confirm_resolved metadata", () => {
|
||||||
|
|||||||
@@ -26,7 +26,13 @@
|
|||||||
|
|
||||||
<!-- [SECTION: TEMPLATE] -->
|
<!-- [SECTION: TEMPLATE] -->
|
||||||
<div class="w-32">
|
<div class="w-32">
|
||||||
<Select bind:value={$locale} {options} />
|
<Select
|
||||||
|
bind:value={$locale}
|
||||||
|
{options}
|
||||||
|
id="global-language-select"
|
||||||
|
name="global_language"
|
||||||
|
aria-label="Language"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- [/SECTION: TEMPLATE] -->
|
<!-- [/SECTION: TEMPLATE] -->
|
||||||
|
|
||||||
|
|||||||
2
run.sh
2
run.sh
@@ -29,6 +29,7 @@ show_help() {
|
|||||||
echo " BACKEND_PORT Port for the backend server (default: 8000)"
|
echo " BACKEND_PORT Port for the backend server (default: 8000)"
|
||||||
echo " FRONTEND_PORT Port for the frontend server (default: 5173)"
|
echo " FRONTEND_PORT Port for the frontend server (default: 5173)"
|
||||||
echo " AGENT_PORT Port for the Gradio agent (default: 7860)"
|
echo " AGENT_PORT Port for the Gradio agent (default: 7860)"
|
||||||
|
echo " AGENT_CONFIRM_TOOLS Enable HITL confirmation before tool calls (default: true)"
|
||||||
echo ""
|
echo ""
|
||||||
echo " LLM providers are fetched from FastAPI /api/agent/llm-config at startup."
|
echo " LLM providers are fetched from FastAPI /api/agent/llm-config at startup."
|
||||||
echo " Configure them in Admin -> LLM Settings."
|
echo " Configure them in Admin -> LLM Settings."
|
||||||
@@ -295,6 +296,7 @@ start_agent() {
|
|||||||
local _color=$'\033[0;35m[Agent]\033[0m '
|
local _color=$'\033[0;35m[Agent]\033[0m '
|
||||||
export GRADIO_SERVER_PORT="$AGENT_PORT"
|
export GRADIO_SERVER_PORT="$AGENT_PORT"
|
||||||
export GRADIO_ALLOW_PORT_FALLBACK="${GRADIO_ALLOW_PORT_FALLBACK:-false}"
|
export GRADIO_ALLOW_PORT_FALLBACK="${GRADIO_ALLOW_PORT_FALLBACK:-false}"
|
||||||
|
export AGENT_CONFIRM_TOOLS="${AGENT_CONFIRM_TOOLS:-true}"
|
||||||
if [ "$DEV_MODE" = "true" ]; then
|
if [ "$DEV_MODE" = "true" ]; then
|
||||||
echo -e "\033[0;35m[Agent]\033[0m Hot-reload enabled via watchfiles (watching src/agent/)"
|
echo -e "\033[0;35m[Agent]\033[0m Hot-reload enabled via watchfiles (watching src/agent/)"
|
||||||
PYTHONUNBUFFERED=1 python3 -m watchfiles --filter python \
|
PYTHONUNBUFFERED=1 python3 -m watchfiles --filter python \
|
||||||
|
|||||||
Reference in New Issue
Block a user