Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
bc9ef5b
feat: agent background edits on notebooks
emrberk Jul 8, 2026
e1704aa
submodule
emrberk Jul 8, 2026
128a681
reviews pt1
emrberk Jul 8, 2026
64e720b
simplifications on controller and freshness/ownership
emrberk Jul 9, 2026
651cd8c
refactor(notebooks): make getCellBasics required, drop getCellSql/get…
emrberk Jul 9, 2026
c8ecf35
chore(notebooks): phase 6 sweep — dead exports, dead activate knob, r…
emrberk Jul 9, 2026
4373d4c
fix(notebooks): report superseded live agent runs as unverified
emrberk Jul 9, 2026
76ec5b7
refactor(notebooks): collapse NotebookController and unify tool dispa…
emrberk Jul 9, 2026
6ea8c49
propagate notebook tool error on seed phase to the user
emrberk Jul 9, 2026
330777c
remove patchCell
emrberk Jul 9, 2026
b227253
cleanup unused functions, make properties required
emrberk Jul 9, 2026
e1e2690
harden type definition, simplifications
emrberk Jul 9, 2026
af23403
reviews
emrberk Jul 9, 2026
641cecf
smoke test fixes
emrberk Jul 9, 2026
9c43ce8
fix(notebooks): include cell name in list_cells payload
emrberk Jul 9, 2026
0569fd4
fix(security): fail closed on validate-ERROR SQL so writes can't bypa…
emrberk Jul 9, 2026
83d4b6a
Revert "fix(security): fail closed on validate-ERROR SQL so writes ca…
emrberk Jul 9, 2026
4df047d
reviews
emrberk Jul 10, 2026
28a7631
reviews for notifications and freshness
emrberk Jul 10, 2026
6e55cf6
submodule
emrberk Jul 10, 2026
df41f42
Merge branch 'feat/notebook-cell-toolbar-redesign' into feat/agent-pa…
emrberk Jul 10, 2026
e503f80
update gitleaksignore
emrberk Jul 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitleaksignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
8f797d824d664ccecaf1ce356509db5a3e3b739d:e2e/tests/enterprise/oidc.spec.js:generic-api-key:176
c506a468bcc77f790cfbb2d412fb9674a47db7cb:e2e/tests/console/mcpBridgePermissions.spec.js:generic-api-key:9
c506a468bcc77f790cfbb2d412fb9674a47db7cb:src/utils/mcp/consumePendingPair.test.ts:generic-api-key:27
bc9ef5bf3b35fabd9db76b1fdad92cf44618e3fe:e2e/utils/mcpFakeWebSocket.js:generic-api-key:5
4 changes: 3 additions & 1 deletion e2e/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,9 @@ Cypress.Commands.add("clearSimulatedWarnings", () => {
cy.execQuery("select simulate_warnings('', '');")
})

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

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

Expand Down
130 changes: 130 additions & 0 deletions e2e/tests/console/agentBackgroundEdits.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
/// <reference types="cypress" />

// E2E coverage for background (passive) agent notebook edits over the MCP
// bridge: creating and editing a notebook never steals the active tab, the
// footer popper announces the change, and View lands on the edited notebook
// with the agent's cells persisted.

const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
const baseUrl = `http://localhost:9999${contextPath}`

const {
installFakeWebSocket,
TEST_BRIDGE_TOKEN,
TEST_BRIDGE_URL,
} = require("../../utils/mcpFakeWebSocket")

const deepLinkSuffix = () =>
`?mcp-pair=1&mcp-ws=${encodeURIComponent(TEST_BRIDGE_URL)}` +
`&mcp-token=${encodeURIComponent(TEST_BRIDGE_TOKEN)}`

const loginAndVisitDeepLink = () => {
cy.visit(`${baseUrl}/${deepLinkSuffix()}`, {
onBeforeLoad: (win) => {
win.localStorage.clear()
win.sessionStorage.clear()
win.indexedDB.deleteDatabase("web-console")
win.localStorage.setItem(
"mcp:permissions",
JSON.stringify({ grantSchemaAccess: true, read: true, write: true }),
)
installFakeWebSocket(win)
},
})
cy.loginWithUserAndPassword()
}

const waitForPaired = () => {
cy.window({ timeout: 10000 }).its("__mcpFakeWS").should("exist")
cy.window({ timeout: 10000 }).should((win) => {
expect(win.__mcpFakeWS.framesOfType("hello").length).to.be.greaterThan(0)
})
cy.window().then((win) => win.__mcpFakeWS.helloAck())
cy.getByDataHook("mcp-bridge-status-pill", { timeout: 10000 }).should(
"contain",
"MCP connected",
)
}

const toolResult = (win, requestId) =>
win.__mcpFakeWS
.framesOfType("tool_result")
.find((r) => r.requestId === requestId)

const activeTabTitle = () =>
cy.get(".chrome-tab[active] .chrome-tab-title").first().invoke("text")

const awaitToolResult = (id) => {
cy.window({ timeout: 10000 }).should((win) => {
expect(toolResult(win, id), `result for ${id}`).to.exist
})
return cy.window().then((win) => {
const result = toolResult(win, id)
expect(result.isError, `tool ${id} errored`).to.not.equal(true)
// The single-line JSON payload may be wrapped by a permissions notice
// above and a since-last-check block below.
const text = result.content[0].text
const payloadLine = text
.split("\n")
.find((line) => line.trimStart().startsWith("{"))
expect(payloadLine, `JSON payload in result for ${id}`).to.exist
return JSON.parse(payloadLine)
})
}

describe("agent background notebook edits (e2e)", () => {
it("creates and fills a notebook in the background; View opens it with the cells persisted", () => {
loginAndVisitDeepLink()
cy.getByDataHook("mcp-pair-consent-connect").click()
waitForPaired()

activeTabTitle().then((initialTab) => {
// Agent creates a notebook — the active tab must not change.
cy.window()
.then((win) =>
awaitToolResult(
win.__mcpFakeWS.toolCall("create_notebook", {
label: "Agent NB",
}),
),
)
.then((created) => {
expect(created.bufferId).to.be.a("number")
expect(created.hint).to.match(/background/i)
cy.get(`.chrome-tab[data-tab-title="Agent NB"]`).should("exist")
activeTabTitle().should("equal", initialTab)

// Read → edit, per the freshness gate; still fully in the background.
cy.window().then((win) =>
awaitToolResult(
win.__mcpFakeWS.toolCall("get_notebook_state", {
buffer_id: created.bufferId,
}),
),
)
cy.window().then((win) =>
awaitToolResult(
win.__mcpFakeWS.toolCall("add_cell", {
buffer_id: created.bufferId,
sql: "SELECT 123 as agent_made_this",
after_cell_id: null,
run: false,
type: "sql",
}),
),
)
activeTabTitle().should("equal", initialTab)

// The footer popper announces the background change.
cy.getByDataHook("agent-changes-popper", { timeout: 10000 })
.should("be.visible")
.and("contain", "Agent NB")

// View lands on the notebook with the agent's cell persisted.
cy.getByDataHook("agent-changes-view").click()
activeTabTitle().should("equal", "Agent NB")
cy.contains("agent_made_this", { timeout: 10000 }).should("exist")
})
})
})
})
88 changes: 5 additions & 83 deletions e2e/tests/console/mcpBridgePermissions.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,89 +5,11 @@
const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || ""
const baseUrl = `http://localhost:9999${contextPath}`

const TEST_BRIDGE_URL = "ws://127.0.0.1:57123"
const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789"
// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped.
const PROTOCOL_VERSION = "1"

// Fake WebSocket installed before the app boots; exposed on `win.__mcpFakeWS`.
const installFakeWebSocket = (win) => {
class FakeWS {
constructor(url) {
this.url = url
this.readyState = 0
this.sent = []
this.listeners = {}
win.__mcpFakeWS = this
// Async "open" mirrors real WebSocket — lets listener registration finish.
win.setTimeout(() => {
this.readyState = 1
this._dispatch("open", {})
}, 0)
}
addEventListener(type, fn) {
if (!this.listeners[type]) this.listeners[type] = new Set()
this.listeners[type].add(fn)
}
removeEventListener(type, fn) {
this.listeners[type]?.delete(fn)
}
send(data) {
if (this.readyState !== 1) {
throw new Error("FakeWS.send while not open")
}
this.sent.push(data)
const parsed = JSON.parse(data)
if (parsed.type === "ping") {
this.receive({
v: PROTOCOL_VERSION,
type: "pong",
nonce: parsed.nonce,
})
}
}
close() {
this.readyState = 3
this._dispatch("close", { code: 1000, reason: "test-close" })
}
receive(payload) {
this._dispatch("message", { data: JSON.stringify(payload) })
}
helloAck() {
this.receive({
v: PROTOCOL_VERSION,
type: "hello_ack",
sessionId: "test-session",
heartbeatIntervalMs: 60000,
seenToolCount: 1,
})
}
toolCall(name, args, requestId) {
const id = requestId || "req-" + Math.random().toString(36).slice(2)
this.receive({
v: PROTOCOL_VERSION,
type: "tool_call",
requestId: id,
name,
arguments: args,
deadlineMs: 15000,
})
return id
}
_dispatch(type, ev) {
this.listeners[type]?.forEach((fn) => fn(ev))
}
framesOfType(type) {
return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type)
}
}
// Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN.
FakeWS.CONNECTING = 0
FakeWS.OPEN = 1
FakeWS.CLOSING = 2
FakeWS.CLOSED = 3
win.WebSocket = FakeWS
}
const {
installFakeWebSocket,
TEST_BRIDGE_TOKEN,
TEST_BRIDGE_URL,
} = require("../../utils/mcpFakeWebSocket")

// Permission classifier calls /api/v1/sql/validate to distinguish DQL vs DDL/DML.
const installValidateIntercept = () => {
Expand Down
92 changes: 92 additions & 0 deletions e2e/utils/mcpFakeWebSocket.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// Fake MCP-bridge WebSocket shared by the bridge e2e specs. Installed before
// the app boots; the instance is exposed on `win.__mcpFakeWS`.

const TEST_BRIDGE_URL = "ws://127.0.0.1:57123"
const TEST_BRIDGE_TOKEN = "abcdef0123456789abcdef0123456789"
Comment thread
emrberk marked this conversation as resolved.
// Must match src/utils/mcp/protocolVersion.ts; mismatches are silently dropped.
const PROTOCOL_VERSION = "1"

const installFakeWebSocket = (win) => {
class FakeWS {
constructor(url) {
this.url = url
this.readyState = 0
this.sent = []
this.listeners = {}
win.__mcpFakeWS = this
// Async "open" mirrors real WebSocket — lets listener registration finish.
win.setTimeout(() => {
this.readyState = 1
this._dispatch("open", {})
}, 0)
}
addEventListener(type, fn) {
if (!this.listeners[type]) this.listeners[type] = new Set()
this.listeners[type].add(fn)
}
removeEventListener(type, fn) {
this.listeners[type]?.delete(fn)
}
send(data) {
if (this.readyState !== 1) {
throw new Error("FakeWS.send while not open")
}
this.sent.push(data)
const parsed = JSON.parse(data)
if (parsed.type === "ping") {
this.receive({
v: PROTOCOL_VERSION,
type: "pong",
nonce: parsed.nonce,
})
}
}
close() {
this.readyState = 3
this._dispatch("close", { code: 1000, reason: "test-close" })
}
receive(payload) {
this._dispatch("message", { data: JSON.stringify(payload) })
}
helloAck() {
this.receive({
v: PROTOCOL_VERSION,
type: "hello_ack",
sessionId: "test-session",
heartbeatIntervalMs: 60000,
seenToolCount: 1,
})
}
toolCall(name, args, requestId) {
const id = requestId || "req-" + Math.random().toString(36).slice(2)
this.receive({
v: PROTOCOL_VERSION,
type: "tool_call",
requestId: id,
name,
arguments: args,
deadlineMs: 15000,
})
return id
}
_dispatch(type, ev) {
this.listeners[type]?.forEach((fn) => fn(ev))
}
framesOfType(type) {
return this.sent.map((s) => JSON.parse(s)).filter((m) => m.type === type)
}
}
// Real-WebSocket statics — MCPBridgeClient reads WebSocket.OPEN.
FakeWS.CONNECTING = 0
FakeWS.OPEN = 1
FakeWS.CLOSING = 2
FakeWS.CLOSED = 3
win.WebSocket = FakeWS
}

module.exports = {
installFakeWebSocket,
PROTOCOL_VERSION,
TEST_BRIDGE_TOKEN,
TEST_BRIDGE_URL,
}
2 changes: 1 addition & 1 deletion src/components/ExplainQueryButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import type { ConversationId } from "../../providers/AIConversationProvider/type
import {
executeAIFlow,
createExplainFlowConfig,
} from "../../utils/executeAIFlow"
} from "../../utils/ai/executeAIFlow"
import { trackEvent } from "../../modules/ConsoleEventTracker"
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"

Expand Down
5 changes: 4 additions & 1 deletion src/components/FixQueryButton/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { useAIStatus } from "../../providers/AIStatusProvider"
import { useAIConversation } from "../../providers/AIConversationProvider"
import { extractErrorByQueryKey } from "../../scenes/Editor/utils"
import type { ExecutionRefs } from "../../scenes/Editor/index"
import { executeAIFlow, createFixFlowConfig } from "../../utils/executeAIFlow"
import {
executeAIFlow,
createFixFlowConfig,
} from "../../utils/ai/executeAIFlow"
import { trackEvent } from "../../modules/ConsoleEventTracker"
import { ConsoleEvent } from "../../modules/ConsoleEventTracker/events"

Expand Down
2 changes: 1 addition & 1 deletion src/components/SetupAIAssistant/ConfigurationModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Input } from "../Input"
import { Switch } from "../Switch"
import { Text } from "../Text"
import { useLocalStorage } from "../../providers/LocalStorageProvider"
import { testApiKey } from "../../utils/aiAssistant"
import { testApiKey } from "../../utils/ai/aiAssistant"
import { StoreKey } from "../../utils/localStorage/types"
import type { CustomProviderDefinition } from "../../providers/LocalStorageProvider/types"
import { toast } from "../Toast"
Expand Down
2 changes: 1 addition & 1 deletion src/components/SetupAIAssistant/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { Switch } from "../Switch"
import { Text } from "../Text"
import { Button } from "../Button"
import { useLocalStorage } from "../../providers/LocalStorageProvider"
import { testApiKey } from "../../utils/aiAssistant"
import { testApiKey } from "../../utils/ai/aiAssistant"
import { StoreKey } from "../../utils/localStorage/types"
import { toast } from "../Toast"
import { Edit } from "@styled-icons/remix-line"
Expand Down
Loading
Loading