chore: remaining pre-existing changes (storage, stream processor, tests, run.sh)

This commit is contained in:
2026-06-30 15:21:15 +03:00
parent 3b4ac807a5
commit 131c7cdfa4
9 changed files with 319 additions and 5 deletions

View File

@@ -414,6 +414,9 @@
class="global-search-container relative flex-1 max-w-xl mx-4 hidden md:block"
>
<input
id="global-search"
name="global_search"
aria-label={$t.common.search}
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
{isSearchFocused ? 'bg-surface-card border border-primary-ring' : ''}"
@@ -470,6 +473,8 @@
{#if globalEnvironments.length > 0}
<div class="hidden lg:flex items-center gap-2">
<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
{isProdContext
? 'border-destructive-ring bg-destructive-light text-destructive focus:ring-destructive-ring'

View File

@@ -28,6 +28,8 @@ export interface StreamProcessorHost {
pendingToolArgs: Record<string, unknown>;
pendingConfirmationRisk: "read" | "write" | "unknown" | 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;
}
@@ -207,6 +209,16 @@ export class StreamProcessor {
this.host.error = meta.detail ?? meta.code ?? "Unknown error";
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:
log("AgentChat.StreamProcessor", "EXPLORE", "Unknown metadata type", { type: meta.type }, "Unhandled metadata type " + meta.type);
}

View File

@@ -19,7 +19,8 @@ export type ConnectionState =
export interface StreamMetadata {
type?: "stream_token" | "tool_start" | "tool_end" | "tool_error"
| "confirm_required" | "confirm_resolved" | "error";
| "confirm_required" | "confirm_resolved" | "error"
| "file_uploaded";
token?: string;
tool?: string;
input?: Record<string, unknown>;
@@ -30,6 +31,22 @@ export interface StreamMetadata {
result?: "confirmed" | "denied";
code?: 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 {
@@ -50,6 +67,12 @@ export interface AgentMessage {
task_id?: string;
toolCalls: ToolCall[];
created_at: string;
/** Attached file info (populated from file_uploaded metadata). */
attachment?: {
file_name: string;
file_path: string;
file_size: number;
};
}
export interface Conversation {

View File

@@ -139,12 +139,26 @@ describe("AgentChatModel — State Machine", () => {
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.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;
const submitBefore = model._client?.submit;
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
@@ -227,6 +241,96 @@ describe("AgentChatModel — Derived State", () => {
model["_messageQueue"] = [{ text: "a" }, { text: "b" }] as any;
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
@@ -278,9 +382,29 @@ describe("AgentChatModel — Metadata Handling", () => {
it("handles confirm_required metadata", () => {
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.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", () => {

View File

@@ -26,7 +26,13 @@
<!-- [SECTION: TEMPLATE] -->
<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>
<!-- [/SECTION: TEMPLATE] -->