Skip to content

Commit fd1bbab

Browse files
authored
Merge branch 'main' into ebk_preserve_query_result_text
2 parents 26fecfa + 2294b1f commit fd1bbab

121 files changed

Lines changed: 11396 additions & 4992 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitleaksignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@
33
8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:176
44
c506a468bcc77f790cfbb2d412fb9674a47db7cb:e2e/tests/console/mcpBridgePermissions.spec.js:generic-api-key:9
55
c506a468bcc77f790cfbb2d412fb9674a47db7cb:src/utils/mcp/consumePendingPair.test.ts:generic-api-key:27
6+
5012b6fa7b20dd00bdc0c1bf188d8e3b48d36c0a:e2e/utils/mcpFakeWebSocket.js:generic-api-key:5

e2e/commands.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,9 @@ Cypress.Commands.add("clearSimulatedWarnings", () => {
122122
cy.execQuery("select simulate_warnings('', '');")
123123
})
124124

125-
Cypress.Commands.add("getByDataHook", (name) => cy.get(`[data-hook="${name}"]`))
125+
Cypress.Commands.add("getByDataHook", (name, options) =>
126+
cy.get(`[data-hook="${name}"]`, options),
127+
)
126128

127129
Cypress.Commands.add("getByRole", (name) => cy.get(`[role="${name}"]`))
128130

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
/// <reference types="cypress" />
2+
3+
// E2E coverage for background (passive) agent notebook edits over the MCP
4+
// bridge: creating and editing a notebook never steals the active tab, the
5+
// footer popper announces the change, and View lands on the edited notebook
6+
// with the agent's cells persisted.
7+
8+
const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
9+
const baseUrl = `http://localhost:9999${contextPath}`
10+
11+
const {
12+
installFakeWebSocket,
13+
TEST_BRIDGE_TOKEN,
14+
TEST_BRIDGE_URL,
15+
} = require("../../utils/mcpFakeWebSocket")
16+
17+
const deepLinkSuffix = () =>
18+
`?mcp-pair=1&mcp-ws=${encodeURIComponent(TEST_BRIDGE_URL)}` +
19+
`&mcp-token=${encodeURIComponent(TEST_BRIDGE_TOKEN)}`
20+
21+
const loginAndVisitDeepLink = () => {
22+
cy.visit(`${baseUrl}/${deepLinkSuffix()}`, {
23+
onBeforeLoad: (win) => {
24+
win.localStorage.clear()
25+
win.sessionStorage.clear()
26+
win.indexedDB.deleteDatabase("web-console")
27+
win.localStorage.setItem(
28+
"mcp:permissions",
29+
JSON.stringify({ grantSchemaAccess: true, read: true, write: true }),
30+
)
31+
installFakeWebSocket(win)
32+
},
33+
})
34+
cy.loginWithUserAndPassword()
35+
}
36+
37+
const waitForPaired = () => {
38+
cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist")
39+
cy.window({ timeout: 10000 }).should((win) => {
40+
expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan(0)
41+
})
42+
cy.window().then((win) => win.__mcpFakeWS.helloAck())
43+
cy.getByDataHook("mcp-bridge-status-pill", { timeout: 10000 }).should(
44+
"contain",
45+
"MCP connected",
46+
)
47+
}
48+
49+
const toolResult = (win, requestId) =>
50+
win.__mcpFakeWS
51+
.framesOfType("tool_result")
52+
.find((r) => r.requestId === requestId)
53+
54+
const activeTabTitle = () =>
55+
cy.get(".chrome-tab[active]").first().invoke("attr", "data-tab-title")
56+
57+
const expectActiveTabTitle = (title) =>
58+
cy.get(".chrome-tab[active]").should("have.attr", "data-tab-title", title)
59+
60+
const awaitToolResult = (id) => {
61+
cy.window({ timeout: 10000 }).should((win) => {
62+
expect(toolResult(win, id), `result for ${id}`).to.exist
63+
})
64+
return cy.window().then((win) => {
65+
const result = toolResult(win, id)
66+
expect(result.isError, `tool ${id} errored`).to.not.equal(true)
67+
// The single-line JSON payload may be wrapped by a permissions notice
68+
// above and a since-last-check block below.
69+
const text = result.content[0].text
70+
const payloadLine = text
71+
.split("\n")
72+
.find((line) => line.trimStart().startsWith("{"))
73+
expect(payloadLine, `JSON payload in result for ${id}`).to.exist
74+
return JSON.parse(payloadLine)
75+
})
76+
}
77+
78+
describe("agent background notebook edits (e2e)", () => {
79+
it("creates and fills a notebook in the background; View opens it with the cells persisted", () => {
80+
loginAndVisitDeepLink()
81+
cy.getByDataHook("mcp-pair-consent-connect").click()
82+
waitForPaired()
83+
84+
activeTabTitle().then((initialTab) => {
85+
// Agent creates a notebook — the active tab must not change.
86+
cy.window()
87+
.then((win) =>
88+
awaitToolResult(
89+
win.__mcpFakeWS.toolCall("create_notebook", {
90+
label: "Agent NB",
91+
}),
92+
),
93+
)
94+
.then((created) => {
95+
expect(created.bufferId).to.be.a("number")
96+
expect(created.hint).to.match(/background/i)
97+
cy.get(`.chrome-tab[data-tab-title="Agent NB"]`).should("exist")
98+
expectActiveTabTitle(initialTab)
99+
100+
// Read → edit, per the freshness gate; still fully in the background.
101+
cy.window().then((win) =>
102+
awaitToolResult(
103+
win.__mcpFakeWS.toolCall("get_notebook_state", {
104+
buffer_id: created.bufferId,
105+
}),
106+
),
107+
)
108+
cy.window().then((win) =>
109+
awaitToolResult(
110+
win.__mcpFakeWS.toolCall("add_cell", {
111+
buffer_id: created.bufferId,
112+
sql: "SELECT 123 as agent_made_this",
113+
after_cell_id: null,
114+
run: false,
115+
type: "sql",
116+
}),
117+
),
118+
)
119+
expectActiveTabTitle(initialTab)
120+
121+
// The footer popper announces the background change.
122+
cy.getByDataHook("agent-changes-popper", { timeout: 10000 })
123+
.should("be.visible")
124+
.and("contain", "Agent NB")
125+
126+
// View lands on the notebook with the agent's cell persisted.
127+
cy.getByDataHook("agent-changes-view").click()
128+
expectActiveTabTitle("Agent NB")
129+
cy.contains("agent_made_this", { timeout: 10000 }).should("exist")
130+
})
131+
})
132+
})
133+
})

e2e/tests/console/mcpBridgePermissions.spec.js

Lines changed: 5 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -5,89 +5,11 @@
55
const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
66
const baseUrl = `http://localhost:9999${contextPath}`
77

8-
const TEST_BRIDGE_URL = "ws://127.0.0.1:57123"
9-
const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789"
10-
// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped.
11-
const PROTOCOL_VERSION = "1"
12-
13-
// Fake WebSocket installed before the app boots; exposed on `win.__mcpFakeWS`.
14-
const installFakeWebSocket = (win) => {
15-
class FakeWS {
16-
constructor(url) {
17-
this.url = url
18-
this.readyState = 0
19-
this.sent = []
20-
this.listeners = {}
21-
win.__mcpFakeWS = this
22-
// Async "open" mirrors real WebSocket — lets listener registration finish.
23-
win.setTimeout(() => {
24-
this.readyState = 1
25-
this._dispatch("open", {})
26-
}, 0)
27-
}
28-
addEventListener(type, fn) {
29-
if (!this.listeners[type]) this.listeners[type] = new Set()
30-
this.listeners[type].add(fn)
31-
}
32-
removeEventListener(type, fn) {
33-
this.listeners[type]?.delete(fn)
34-
}
35-
send(data) {
36-
if (this.readyState !== 1) {
37-
throw new Error("FakeWS.send while not open")
38-
}
39-
this.sent.push(data)
40-
const parsed = JSON.parse(data)
41-
if (parsed.type === "ping") {
42-
this.receive({
43-
v: PROTOCOL_VERSION,
44-
type: "pong",
45-
nonce: parsed.nonce,
46-
})
47-
}
48-
}
49-
close() {
50-
this.readyState = 3
51-
this._dispatch("close", { code: 1000, reason: "test-close" })
52-
}
53-
receive(payload) {
54-
this._dispatch("message", { data: JSON.stringify(payload) })
55-
}
56-
helloAck() {
57-
this.receive({
58-
v: PROTOCOL_VERSION,
59-
type: "hello_ack",
60-
sessionId: "test-session",
61-
heartbeatIntervalMs: 60000,
62-
seenToolCount: 1,
63-
})
64-
}
65-
toolCall(name, args, requestId) {
66-
const id = requestId || "req-" + Math.random().toString(36).slice(2)
67-
this.receive({
68-
v: PROTOCOL_VERSION,
69-
type: "tool_call",
70-
requestId: id,
71-
name,
72-
arguments: args,
73-
deadlineMs: 15000,
74-
})
75-
return id
76-
}
77-
_dispatch(type, ev) {
78-
this.listeners[type]?.forEach((fn) => fn(ev))
79-
}
80-
framesOfType(type) {
81-
return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type)
82-
}
83-
}
84-
// Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN.
85-
FakeWS.CONNECTING = 0
86-
FakeWS.OPEN = 1
87-
FakeWS.CLOSING = 2
88-
FakeWS.CLOSED = 3
89-
win.WebSocket = FakeWS
90-
}
8+
const {
9+
installFakeWebSocket,
10+
TEST_BRIDGE_TOKEN,
11+
TEST_BRIDGE_URL,
12+
} = require("../../utils/mcpFakeWebSocket")
9113

9214
// Permission classifier calls /api/v1/sql/validate to distinguish DQL vs DDL/DML.
9315
const installValidateIntercept = () => {

e2e/tests/console/tableDetails.spec.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,8 @@ describe("TableDetailsDrawer", () => {
843843
"AI Assistant is not configured",
844844
)
845845

846+
cy.getByDataHook("table-details-tab-details").realHover()
847+
cy.getByDataHook("tooltip").should("not.exist")
846848
cy.getByDataHook("table-details-tab-details").click()
847849
cy.getByDataHook("table-details-explain-ai").should("be.disabled")
848850
cy.getByDataHook("table-details-explain-ai").realHover()
@@ -892,6 +894,9 @@ describe("TableDetailsDrawer", () => {
892894
"contain",
893895
"Schema access is not granted to this model",
894896
)
897+
898+
cy.getByDataHook("table-details-tab-details").realHover()
899+
cy.getByDataHook("tooltip").should("not.exist")
895900
cy.getByDataHook("table-details-tab-details").click()
896901
cy.getByDataHook("table-details-explain-ai").should("be.disabled")
897902
cy.getByDataHook("table-details-copy-ddl").should("be.visible").click()

e2e/utils/mcpFakeWebSocket.js

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Fake MCP-bridge WebSocket shared by the bridge e2e specs. Installed before
2+
// the app boots; the instance is exposed on `win.__mcpFakeWS`.
3+
4+
const TEST_BRIDGE_URL = "ws://127.0.0.1:57123"
5+
const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789"
6+
// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped.
7+
const PROTOCOL_VERSION = "1"
8+
9+
const installFakeWebSocket = (win) => {
10+
class FakeWS {
11+
constructor(url) {
12+
this.url = url
13+
this.readyState = 0
14+
this.sent = []
15+
this.listeners = {}
16+
win.__mcpFakeWS = this
17+
// Async "open" mirrors real WebSocket — lets listener registration finish.
18+
win.setTimeout(() => {
19+
this.readyState = 1
20+
this._dispatch("open", {})
21+
}, 0)
22+
}
23+
addEventListener(type, fn) {
24+
if (!this.listeners[type]) this.listeners[type] = new Set()
25+
this.listeners[type].add(fn)
26+
}
27+
removeEventListener(type, fn) {
28+
this.listeners[type]?.delete(fn)
29+
}
30+
send(data) {
31+
if (this.readyState !== 1) {
32+
throw new Error("FakeWS.send while not open")
33+
}
34+
this.sent.push(data)
35+
const parsed = JSON.parse(data)
36+
if (parsed.type === "ping") {
37+
this.receive({
38+
v: PROTOCOL_VERSION,
39+
type: "pong",
40+
nonce: parsed.nonce,
41+
})
42+
}
43+
}
44+
close() {
45+
this.readyState = 3
46+
this._dispatch("close", { code: 1000, reason: "test-close" })
47+
}
48+
receive(payload) {
49+
this._dispatch("message", { data: JSON.stringify(payload) })
50+
}
51+
helloAck() {
52+
this.receive({
53+
v: PROTOCOL_VERSION,
54+
type: "hello_ack",
55+
sessionId: "test-session",
56+
heartbeatIntervalMs: 60000,
57+
seenToolCount: 1,
58+
})
59+
}
60+
toolCall(name, args, requestId) {
61+
const id = requestId || "req-" + Math.random().toString(36).slice(2)
62+
this.receive({
63+
v: PROTOCOL_VERSION,
64+
type: "tool_call",
65+
requestId: id,
66+
name,
67+
arguments: args,
68+
deadlineMs: 15000,
69+
})
70+
return id
71+
}
72+
_dispatch(type, ev) {
73+
this.listeners[type]?.forEach((fn) => fn(ev))
74+
}
75+
framesOfType(type) {
76+
return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type)
77+
}
78+
}
79+
// Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN.
80+
FakeWS.CONNECTING = 0
81+
FakeWS.OPEN = 1
82+
FakeWS.CLOSING = 2
83+
FakeWS.CLOSED = 3
84+
win.WebSocket = FakeWS
85+
}
86+
87+
module.exports = {
88+
installFakeWebSocket,
89+
PROTOCOL_VERSION,
90+
TEST_BRIDGE_TOKEN,
91+
TEST_BRIDGE_URL,
92+
}

src/components/ExplainQueryButton/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import type { ConversationId } from "../../providers/AIConversationProvider/type
1313
import {
1414
executeAIFlow,
1515
createExplainFlowConfig,
16-
} from "../../utils/executeAIFlow"
16+
} from "../../utils/ai/executeAIFlow"
1717
import { trackEvent } from "../../modules/ConsoleEventTracker"
1818
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"
1919

src/components/FixQueryButton/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { useAIStatus } from "../../providers/AIStatusProvider"
1111
import { useAIConversation } from "../../providers/AIConversationProvider"
1212
import { extractErrorByQueryKey } from "../../scenes/Editor/utils"
1313
import type { ExecutionRefs } from "../../scenes/Editor/index"
14-
import { executeAIFlow, createFixFlowConfig } from "../../utils/executeAIFlow"
14+
import {
15+
executeAIFlow,
16+
createFixFlowConfig,
17+
} from "../../utils/ai/executeAIFlow"
1518
import { trackEvent } from "../../modules/ConsoleEventTracker"
1619
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"
1720

src/components/Overlay/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import styled, { css } from "styled-components"
33
import { Overlay as RadixDialogOverlay } from "@radix-ui/react-dialog"
44
import { Overlay as RadixAlertDialogOverlay } from "@radix-ui/react-alert-dialog"
55

6+
export const OVERLAY_Z_INDEX = 100
7+
68
const overlayShow = css`
79
@keyframes overlayShow {
810
from {
@@ -29,7 +31,7 @@ const StyledOverlay = styled.div`
2931
background-color: ${({ theme }) => theme.color.overlayBackground};
3032
position: fixed;
3133
inset: 0;
32-
z-index: 100;
34+
z-index: ${OVERLAY_Z_INDEX};
3335
3436
${overlayShow}
3537
${overlayHide}

0 commit comments

Comments
 (0)