Files
dbx/apps/desktop/src/lib/__tests__/ai/aiContext.spec.ts
T

239 lines
8.5 KiB
TypeScript
Vendored

import { beforeEach, describe, expect, it, vi } from "vitest";
import { aiSchemaSelectionSupported, buildAiContext, resolveAiDatabaseTarget, resolveAiMentionDatabase, resolveAiNamespaceSelection, resolveDefaultAiSchema, runAgentStream } from "@/lib/ai/ai";
import type { AiConfig } from "@/types/ai";
import type { ConnectionConfig, QueryTab } from "@/types/database";
const apiMock = vi.hoisted(() => ({
listTables: vi.fn(),
getColumns: vi.fn(),
listIndexes: vi.fn(),
listForeignKeys: vi.fn(),
aiAgentStream: vi.fn(),
}));
vi.mock("@/lib/backend/api", () => apiMock);
function sqliteConnection(database?: string): ConnectionConfig {
return {
id: "sqlite-1",
name: "SQLite",
db_type: "sqlite",
host: "/tmp/real.sqlite",
port: 0,
username: "",
password: "",
database,
};
}
function queryTab(database: string, schema?: string): QueryTab {
return {
id: "tab-1",
title: "Query",
connectionId: "sqlite-1",
database,
schema,
sql: "",
isExecuting: false,
isCancelling: false,
isExplaining: false,
mode: "query",
};
}
function damengConnection(database = "APPDB"): ConnectionConfig {
return {
id: "dameng-1",
name: "Dameng",
db_type: "dameng",
host: "127.0.0.1",
port: 5236,
username: "APP_USER",
password: "",
database,
};
}
function postgresConnection(database = "app"): ConnectionConfig {
return {
id: "postgres-1",
name: "PostgreSQL",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "app_user",
password: "",
database,
};
}
function aiConfig(): AiConfig {
return {
provider: "openai",
apiKey: "",
authMethod: "api-key",
endpoint: "",
model: "test-model",
apiStyle: "responses",
};
}
describe("SQLite AI context routing", () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.listTables.mockResolvedValue([]);
apiMock.getColumns.mockResolvedValue([]);
apiMock.listIndexes.mockResolvedValue([]);
apiMock.listForeignKeys.mockResolvedValue([]);
});
it("uses main for a stale path-shaped SQLite database value", async () => {
const context = await buildAiContext(queryTab("/tmp/stale.sqlite"), sqliteConnection("/tmp/legacy.sqlite"));
expect(context.database).toBe("main");
expect(apiMock.listTables).toHaveBeenCalledWith("sqlite-1", "main", "main");
});
it("preserves an attached SQLite database alias", async () => {
const context = await buildAiContext(queryTab("analytics"), sqliteConnection());
expect(context.database).toBe("analytics");
expect(apiMock.listTables).toHaveBeenCalledWith("sqlite-1", "analytics", "analytics");
});
});
describe("Dameng AI context routing", () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.listTables.mockResolvedValue([]);
apiMock.getColumns.mockResolvedValue([]);
apiMock.listIndexes.mockResolvedValue([]);
apiMock.listForeignKeys.mockResolvedValue([]);
apiMock.aiAgentStream.mockResolvedValue("done");
});
it("keeps the connection database while routing metadata through the selected schema", async () => {
const tab = { ...queryTab("APPDB", "REPORTING"), connectionId: "dameng-1" };
const context = await buildAiContext(tab, damengConnection());
expect(context.database).toBe("APPDB");
expect(context.schema).toBe("REPORTING");
expect(apiMock.listTables).toHaveBeenCalledWith("dameng-1", "APPDB", "REPORTING");
await runAgentStream(
{
config: aiConfig(),
action: "general",
mode: "agent",
instruction: "inspect orders",
context,
},
[],
vi.fn(),
"session-1",
);
expect(apiMock.aiAgentStream).toHaveBeenCalledWith("session-1", expect.any(Object), "dameng-1", "APPDB", "REPORTING", "dameng", expect.any(Function), "agent", false, undefined, undefined, undefined, undefined);
});
it("models the AI selector as a schema and chooses the connected user by default", () => {
const connection = damengConnection();
expect(resolveAiNamespaceSelection(queryTab("APPDB", "REPORTING"), connection)).toEqual({
kind: "schema",
value: "REPORTING",
});
expect(resolveDefaultAiSchema(connection, ["ARCHIVE", "APP_USER", "REPORTING"])).toBe("APP_USER");
expect(resolveDefaultAiSchema(connection, ["REPORTING", "ARCHIVE"])).toBe("REPORTING");
});
it("lists @ mention tables from the database picked in the composer", () => {
const connection = postgresConnection();
expect(resolveAiMentionDatabase(queryTab("db_first"), connection, ["db_second"])).toBe("db_second");
expect(resolveAiMentionDatabase(queryTab("db_first"), connection, ["db_second", "db_first"])).toBe("db_second");
expect(resolveAiMentionDatabase(queryTab("db_first"), connection, [])).toBe("db_first");
expect(resolveAiMentionDatabase(queryTab("db_first"), damengConnection(), ["ignored"])).toBe("db_first");
});
it("does not change non-Dameng namespace behavior", () => {
expect(resolveAiDatabaseTarget(queryTab("analytics"), sqliteConnection())).toEqual({ database: "analytics" });
});
});
describe("PostgreSQL AI schema routing", () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.listTables.mockResolvedValue([]);
apiMock.getColumns.mockResolvedValue([]);
apiMock.listIndexes.mockResolvedValue([]);
apiMock.listForeignKeys.mockResolvedValue([]);
apiMock.aiAgentStream.mockResolvedValue("done");
});
it("scopes metadata and agent tools to the selected schema", async () => {
const tab = { ...queryTab("app", "main_chatdr"), connectionId: "postgres-1" };
const connection = postgresConnection();
const context = await buildAiContext(tab, connection);
expect(resolveAiDatabaseTarget(tab, connection)).toEqual({ database: "app", schema: "main_chatdr" });
expect(context.schema).toBe("main_chatdr");
expect(apiMock.listTables).toHaveBeenCalledWith("postgres-1", "app", "main_chatdr");
await runAgentStream(
{
config: aiConfig(),
action: "general",
mode: "agent",
instruction: "inspect chat_dial_in",
context,
},
[],
vi.fn(),
"session-postgres",
);
expect(apiMock.aiAgentStream).toHaveBeenCalledWith("session-postgres", expect.any(Object), "postgres-1", "app", "main_chatdr", "postgres", expect.any(Function), "agent", false, undefined, undefined, undefined, undefined);
});
});
describe("AI schema selector visibility", () => {
it("matches the effective type consumed by resolveAiDatabaseTarget", () => {
const gbaseLikeMysql: ConnectionConfig = {
id: "gbase-1",
name: "GBase",
db_type: "gbase",
host: "localhost",
port: 5258,
username: "dbx",
password: "",
database: "app",
};
// gbase is schema-aware at the raw metadata level but maps to a
// MySQL-like effective type, so the AI target ignores schema selections
// and the selector must stay hidden to avoid a silently dropped choice.
expect(aiSchemaSelectionSupported(gbaseLikeMysql)).toBe(false);
const tab: QueryTab = queryTab("app", "sysmaster");
expect(resolveAiDatabaseTarget(tab, gbaseLikeMysql)).toEqual({ database: "app" });
const postgres: ConnectionConfig = { ...gbaseLikeMysql, id: "pg-1", db_type: "postgres" };
expect(aiSchemaSelectionSupported(postgres)).toBe(true);
expect(resolveAiDatabaseTarget(queryTab("app", "public"), postgres)).toEqual({ database: "app", schema: "public" });
});
});
describe("Plugin AI context", () => {
beforeEach(() => vi.clearAllMocks());
it.each([false, true])("skips database metadata even with stale table context: %s", async (withTable) => {
const connection: ConnectionConfig = { ...postgresConnection(), db_type: "plugin", plugin_id: "sample.plugin" };
const tab = { ...queryTab("stale-db", "public"), connectionId: connection.id, tableMeta: withTable ? { schema: "public", tableName: "stale_table", columns: [] } : undefined };
const context = await buildAiContext(tab, connection, { mentionedTables: [{ schema: "public", table: "stale_mention" }], sqlFiles: [{ name: "notes.sql", content: "SELECT 1" }] });
expect(context.databaseType).toBe("plugin");
expect(context.connectionName).toBe(connection.name);
expect(context.tables).toEqual([]);
expect(context.truncated).toBe(false);
expect(context.sqlFiles).toEqual([{ name: "notes.sql", content: "SELECT 1" }]);
expect(apiMock.listTables).not.toHaveBeenCalled();
expect(apiMock.getColumns).not.toHaveBeenCalled();
expect(apiMock.listIndexes).not.toHaveBeenCalled();
expect(apiMock.listForeignKeys).not.toHaveBeenCalled();
});
});