From b7023ce33e879aa0c415ee37b66b36480dcd858a Mon Sep 17 00:00:00 2001 From: cstns Date: Mon, 22 Jun 2026 19:28:25 +0300 Subject: [PATCH 01/40] Implement MCP server routes, feature flags, and related tests --- forge/ee/lib/index.js | 3 + forge/ee/routes/index.js | 2 +- forge/ee/routes/mcp/index.js | 206 +------------------ forge/ee/routes/mcp/registrations.js | 198 ++++++++++++++++++ forge/ee/routes/mcp/server.js | 34 +++ package-lock.json | 30 ++- package.json | 2 + test/unit/forge/ee/routes/mcp/server_spec.js | 172 ++++++++++++++++ 8 files changed, 444 insertions(+), 203 deletions(-) create mode 100644 forge/ee/routes/mcp/registrations.js create mode 100644 forge/ee/routes/mcp/server.js create mode 100644 test/unit/forge/ee/routes/mcp/server_spec.js diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 8b979786d9..65abf24ef8 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -52,6 +52,9 @@ module.exports = fp(async function (app, opts) { // Set the expert assistant Feature Flag app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // Set the expert platform automation Feature Flag (MCP platform tools server) + app.config.features.register('expertPlatformAutomation', isAiEnabled && (app.config?.expert?.enabled ?? false), true) + // temporary until FF Expert Insights can be enabled on Self Hosted EE instance const isInsightsEnabled = isAiEnabled && app.config?.expert?.enabled && app.config?.expert?.insights?.enabled app.config.features.register('expertInsights', isInsightsEnabled ?? false, false) diff --git a/forge/ee/routes/index.js b/forge/ee/routes/index.js index ba9f1b6f98..14ef4c5b03 100644 --- a/forge/ee/routes/index.js +++ b/forge/ee/routes/index.js @@ -39,7 +39,7 @@ module.exports = async function (app) { if (app.config.tables?.enabled) { await app.register(require('./tables'), { prefix: '/api/v1/teams/:teamId/databases', logLevel: app.config.logging.http }) } - await app.register(require('./mcp'), { prefix: '/api/v1/teams/:teamId/mcp', logLevel: app.config.logging.http }) + await app.register(require('./mcp'), { logLevel: app.config.logging.http }) await app.register(require('./autoUpdateStacks'), { prefix: '/api/v1/projects/:projectId/autoUpdateStack', logLevel: app.config.logging.http }) await app.register(require('./expert'), { prefix: '/api/v1/expert', logLevel: app.config.logging.http }) diff --git a/forge/ee/routes/mcp/index.js b/forge/ee/routes/mcp/index.js index ed8da3b08e..1b692df3dd 100644 --- a/forge/ee/routes/mcp/index.js +++ b/forge/ee/routes/mcp/index.js @@ -1,198 +1,12 @@ +/** + * MCP routes + * + * - registrations: NR instance/device MCP server registration and discovery + * - server: Platform MCP server endpoint for external AI agents + * + * @param {import('../../../forge').ForgeApplication} app + */ module.exports = async function (app) { - app.addHook('preHandler', async (request, reply) => { - if (request.params.teamId !== undefined || request.params.teamSlug !== undefined) { - if (!request.team) { - // For a :teamId route, we can now lookup the full team object - request.team = await app.db.models.Team.byId(request.params.teamId) - if (!request.team) { - reply.code(404).send({ code: 'not_found', error: 'Not Found' }) - } - } - } - if (request.session.User) { - request.sessionUser = true - request.instanceTokenReq = false - if (!request.teamMembership) { - request.teamMembership = await request.session.User.getTeamMembership(request.team.id) - } - } else if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { - // this is a request from a project or device - request.sessionUserReq = false - request.instanceTokenReq = true - } else { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - throw new Error('Unauthorized') - } - }) - - /** - * Get the MCP servers for a team - * @name /api/v1/teams/:teamId/mcp - * @static - * @memberof forge.routes.api.team.mcp - */ - app.get('/', { - preHandler: app.needsPermission('team:mcp:list'), - schema: { - summary: '', - tags: ['MCP'], - params: { - type: 'object', - properties: { - teamId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object', - properties: { - count: { type: 'number' }, - servers: { $ref: 'MCPRegistrationSummaryList' } - } - }, - '4xx': { - $ref: 'APIError' - }, - 500: { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - try { - const mcpServers = await app.db.models.MCPRegistration.byTeam(request.params.teamId) - const mcpServersView = app.db.views.MCPRegistrations.MCPRegistrationSummaryList(mcpServers) - reply.send({ count: mcpServers.length, servers: mcpServersView }) - } catch (err) { - reply.status(500).send({ code: 'unexpected_error', error: 'Failed to find mcp entries for team' }) - } - }) - - app.post('/:type/:typeId/:nodeId', { - preHandler: async (request, reply) => { - if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { - // all good - } else { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - } - }, - schema: { - summary: '', - tags: ['MCP'], - params: { - type: 'object', - properties: { - teamId: { type: 'string' }, - type: { type: 'string' }, - typeId: { type: 'string' }, - nodeId: { type: 'string' } - } - }, - body: { - type: 'object', - properties: { - name: { type: 'string' }, - endpointRoute: { type: 'string' }, - protocol: { type: 'string' }, - title: { type: 'string' }, - version: { type: 'string' }, - description: { type: 'string' } - } - }, - response: { - 200: { - type: 'object' - }, - 500: { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - try { - let typeId = request.params.typeId - if (request.params.type === 'device') { - const device = await app.db.models.Device.byId(request.params.typeId) - if (!device) { - throw new Error(`Device '${request.params.typeId}' not found`) - } - typeId = device.id - } else if (request.params.type === 'instance') { - const project = await app.db.models.Project.byId(request.params.typeId) - if (!project) { - throw new Error(`Instance '${request.params.typeId}' not found`) - } - } else { - throw new Error(`Unknown MCP target type '${request.params.type}'`) - } - - await app.db.models.MCPRegistration.upsert({ - targetType: request.params.type, - targetId: typeId, - nodeId: request.params.nodeId, - title: request.body.title, - version: request.body.version, - description: request.body.description, - name: request.body.name, - endpointRoute: request.body.endpointRoute, - protocol: request.body.protocol, - TeamId: request.team.id - }, { - fields: ['name', 'endpointRoute', 'title', 'version', 'description'], - conflictFields: ['TeamId', 'targetType', 'nodeId', 'targetId'] - }) - } catch (err) { - app.log.error(`register MCP Server ${err.toString()}`) - reply.status(500).send({ code: 'unexpected_error', error: 'Failed to create mcp entry' }) - return - } - reply.send({}) - }) - - app.delete('/:type/:typeId/:nodeId', { - preHandler: async (request, reply) => { - if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { - // all good - } else { - reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) - } - }, - schema: { - summary: '', - tags: ['MCP'], - params: { - type: 'object', - properties: { - teamId: { type: 'string' }, - type: { type: 'string' }, - typeId: { type: 'string' }, - nodeId: { type: 'string' } - } - }, - response: { - 200: { - type: 'object' - }, - '4xx': { - $ref: 'APIError' - }, - 500: { - $ref: 'APIError' - } - } - } - }, async (request, reply) => { - try { - const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, request.params.typeId, request.params.nodeId) - if (mcpServer) { - await mcpServer.destroy() - reply.send({}) - } else { - reply.status(404).send({ code: 'not_found', error: 'MCP server not found' }) - } - } catch (err) { - app.log.error(`delete MCP Server ${err.toString()}`) - reply.status(500).send({ code: 'unexpected_error', error: 'Failed to delete mcp entry' }) - } - }) + await app.register(require('./registrations'), { prefix: '/api/v1/teams/:teamId/mcp', logLevel: app.config.logging.http }) + await app.register(require('./server'), { prefix: '/api/v1/mcp', logLevel: app.config.logging.http }) } diff --git a/forge/ee/routes/mcp/registrations.js b/forge/ee/routes/mcp/registrations.js new file mode 100644 index 0000000000..ed8da3b08e --- /dev/null +++ b/forge/ee/routes/mcp/registrations.js @@ -0,0 +1,198 @@ +module.exports = async function (app) { + app.addHook('preHandler', async (request, reply) => { + if (request.params.teamId !== undefined || request.params.teamSlug !== undefined) { + if (!request.team) { + // For a :teamId route, we can now lookup the full team object + request.team = await app.db.models.Team.byId(request.params.teamId) + if (!request.team) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + } + } + } + if (request.session.User) { + request.sessionUser = true + request.instanceTokenReq = false + if (!request.teamMembership) { + request.teamMembership = await request.session.User.getTeamMembership(request.team.id) + } + } else if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { + // this is a request from a project or device + request.sessionUserReq = false + request.instanceTokenReq = true + } else { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + throw new Error('Unauthorized') + } + }) + + /** + * Get the MCP servers for a team + * @name /api/v1/teams/:teamId/mcp + * @static + * @memberof forge.routes.api.team.mcp + */ + app.get('/', { + preHandler: app.needsPermission('team:mcp:list'), + schema: { + summary: '', + tags: ['MCP'], + params: { + type: 'object', + properties: { + teamId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object', + properties: { + count: { type: 'number' }, + servers: { $ref: 'MCPRegistrationSummaryList' } + } + }, + '4xx': { + $ref: 'APIError' + }, + 500: { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + const mcpServers = await app.db.models.MCPRegistration.byTeam(request.params.teamId) + const mcpServersView = app.db.views.MCPRegistrations.MCPRegistrationSummaryList(mcpServers) + reply.send({ count: mcpServers.length, servers: mcpServersView }) + } catch (err) { + reply.status(500).send({ code: 'unexpected_error', error: 'Failed to find mcp entries for team' }) + } + }) + + app.post('/:type/:typeId/:nodeId', { + preHandler: async (request, reply) => { + if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { + // all good + } else { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + } + }, + schema: { + summary: '', + tags: ['MCP'], + params: { + type: 'object', + properties: { + teamId: { type: 'string' }, + type: { type: 'string' }, + typeId: { type: 'string' }, + nodeId: { type: 'string' } + } + }, + body: { + type: 'object', + properties: { + name: { type: 'string' }, + endpointRoute: { type: 'string' }, + protocol: { type: 'string' }, + title: { type: 'string' }, + version: { type: 'string' }, + description: { type: 'string' } + } + }, + response: { + 200: { + type: 'object' + }, + 500: { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + let typeId = request.params.typeId + if (request.params.type === 'device') { + const device = await app.db.models.Device.byId(request.params.typeId) + if (!device) { + throw new Error(`Device '${request.params.typeId}' not found`) + } + typeId = device.id + } else if (request.params.type === 'instance') { + const project = await app.db.models.Project.byId(request.params.typeId) + if (!project) { + throw new Error(`Instance '${request.params.typeId}' not found`) + } + } else { + throw new Error(`Unknown MCP target type '${request.params.type}'`) + } + + await app.db.models.MCPRegistration.upsert({ + targetType: request.params.type, + targetId: typeId, + nodeId: request.params.nodeId, + title: request.body.title, + version: request.body.version, + description: request.body.description, + name: request.body.name, + endpointRoute: request.body.endpointRoute, + protocol: request.body.protocol, + TeamId: request.team.id + }, { + fields: ['name', 'endpointRoute', 'title', 'version', 'description'], + conflictFields: ['TeamId', 'targetType', 'nodeId', 'targetId'] + }) + } catch (err) { + app.log.error(`register MCP Server ${err.toString()}`) + reply.status(500).send({ code: 'unexpected_error', error: 'Failed to create mcp entry' }) + return + } + reply.send({}) + }) + + app.delete('/:type/:typeId/:nodeId', { + preHandler: async (request, reply) => { + if (request.session.ownerType === 'project' || request.session.ownerType === 'device') { + // all good + } else { + reply.code(403).send({ code: 'unauthorized', error: 'Unauthorized' }) + } + }, + schema: { + summary: '', + tags: ['MCP'], + params: { + type: 'object', + properties: { + teamId: { type: 'string' }, + type: { type: 'string' }, + typeId: { type: 'string' }, + nodeId: { type: 'string' } + } + }, + response: { + 200: { + type: 'object' + }, + '4xx': { + $ref: 'APIError' + }, + 500: { + $ref: 'APIError' + } + } + } + }, async (request, reply) => { + try { + const mcpServer = await app.db.models.MCPRegistration.byTypeAndIDs(request.params.type, request.params.typeId, request.params.nodeId) + if (mcpServer) { + await mcpServer.destroy() + reply.send({}) + } else { + reply.status(404).send({ code: 'not_found', error: 'MCP server not found' }) + } + } catch (err) { + app.log.error(`delete MCP Server ${err.toString()}`) + reply.status(500).send({ code: 'unexpected_error', error: 'Failed to delete mcp entry' }) + } + }) +} diff --git a/forge/ee/routes/mcp/server.js b/forge/ee/routes/mcp/server.js new file mode 100644 index 0000000000..989276c372 --- /dev/null +++ b/forge/ee/routes/mcp/server.js @@ -0,0 +1,34 @@ +/** + * MCP Platform Tools Server + * + * Exposes FlowFuse platform management capabilities as MCP tools. + * + * @param {import('../../../forge').ForgeApplication} app + */ +module.exports = async function (app) { + app.addHook('preHandler', async (request, reply) => { + // Gate on feature flag + if (!app.config.features.enabled('expertPlatformAutomation')) { + reply.code(404).send({ code: 'not_found', error: 'Not Found' }) + return + } + // Require a user-owned PAT (not device/project/broker tokens) + if (!request.session?.User) { + reply.code(401).send({ code: 'unauthorized', error: 'unauthorized' }) + } + }) + + // POST handler will be implemented in #7429 + app.post('/', async (request, reply) => { + reply.code(501).send({ code: 'not_implemented', error: 'MCP endpoint not yet implemented' }) + }) + + // GET and DELETE are not supported in stateless mode + app.get('/', async (request, reply) => { + reply.code(405).send({ code: 'method_not_allowed', error: 'Method Not Allowed. Use POST for MCP requests.' }) + }) + + app.delete('/', async (request, reply) => { + reply.code(405).send({ code: 'method_not_allowed', error: 'Method Not Allowed. Stateless mode, no sessions to terminate.' }) + }) +} diff --git a/package-lock.json b/package-lock.json index c38358d7fb..41de50e556 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@heroicons/vue": "2.1.5", "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^5.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@node-saml/passport-saml": "^5.0.0", "@redis/client": "^6.0.0", "@sentry/node": "^10.54.0", @@ -78,6 +79,7 @@ "vue-shepherd": "^3.0.0", "vue3-google-login": "^2.0.33", "yaml": "^2.3.1", + "zod": "^4.4.3", "zxcvbn": "^4.4.2" }, "bin": { @@ -4836,6 +4838,15 @@ "node": ">=16.x" } }, + "node_modules/@flowfuse/nr-assistant/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@flowfuse/nr-file-nodes": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/@flowfuse/nr-file-nodes/-/nr-file-nodes-0.0.10.tgz", @@ -27234,9 +27245,9 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -30052,6 +30063,13 @@ "onnxruntime-web": "^1.22.0", "semver": "^7.7.2", "zod": "^3.25.76" + }, + "dependencies": { + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + } } }, "@flowfuse/nr-file-nodes": { @@ -45316,9 +45334,9 @@ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, "zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==" }, "zod-to-json-schema": { "version": "3.25.2", diff --git a/package.json b/package.json index 0b1719c256..fb1a9b8e3b 100644 --- a/package.json +++ b/package.json @@ -76,6 +76,7 @@ "@heroicons/vue": "2.1.5", "@levminer/speakeasy": "^1.4.2", "@node-red/util": "^5.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@node-saml/passport-saml": "^5.0.0", "@redis/client": "^6.0.0", "@sentry/node": "^10.54.0", @@ -125,6 +126,7 @@ "vue-shepherd": "^3.0.0", "vue3-google-login": "^2.0.33", "yaml": "^2.3.1", + "zod": "^4.4.3", "zxcvbn": "^4.4.2" }, "devDependencies": { diff --git a/test/unit/forge/ee/routes/mcp/server_spec.js b/test/unit/forge/ee/routes/mcp/server_spec.js new file mode 100644 index 0000000000..8e40249c74 --- /dev/null +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -0,0 +1,172 @@ +const should = require('should') // eslint-disable-line no-unused-vars + +const setup = require('../../setup') + +describe('MCP Platform Tools Server', function () { + describe('Feature flag enabled (default)', function () { + let app + const TestObjects = {} + + before(async function () { + app = await setup({ + license: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZkNDFmNmRjLTBmM2QtNGFmNy1hNzk0LWIyNWFhNGJmYTliZCIsInZlciI6IjIwMjQtMDMtMDQiLCJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGdXNlIERldmVsb3BtZW50IiwibmJmIjoxNzMwNjc4NDAwLCJleHAiOjIwNzc3NDcyMDAsIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxMCwidGVhbXMiOjEwLCJpbnN0YW5jZXMiOjEwLCJtcXR0Q2xpZW50cyI6NiwidGllciI6ImVudGVycHJpc2UiLCJkZXYiOnRydWUsImlhdCI6MTczMDcyMTEyNH0.02KMRf5kogkpH3HXHVSGprUm0QQFLn21-3QIORhxFgRE9N5DIE8YnTH_f8W_21T6TlYbDUmf4PtWyj120HTM2w', + ai: { enabled: true }, + expert: { enabled: true } + }) + + TestObjects.alicePAT = await app.db.controllers.AccessToken.createPersonalAccessToken( + app.user, + null, + null, + 'alice-pat' + ) + }) + + after(async function () { + await app.close() + }) + + it('should register the expertPlatformAutomation feature flag', async function () { + app.config.features.enabled('expertPlatformAutomation').should.equal(true) + }) + + it('should return 501 for POST /api/v1/mcp with valid PAT (endpoint shell)', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + }, + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(501) + }) + + it('should return 401 for POST /api/v1/mcp without auth', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(401) + }) + + it('should return 401 for POST /api/v1/mcp with invalid token', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: 'Bearer invalid-token' + }, + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(401) + }) + + it('should return 405 for GET /api/v1/mcp', async function () { + const response = await app.inject({ + method: 'GET', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + } + }) + response.statusCode.should.equal(405) + }) + + it('should return 405 for DELETE /api/v1/mcp', async function () { + const response = await app.inject({ + method: 'DELETE', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + } + }) + response.statusCode.should.equal(405) + }) + + it('should not break existing registration routes', async function () { + const { token } = await app.instance.refreshAuthTokens() + const response = await app.inject({ + method: 'POST', + url: `/api/v1/teams/${app.team.hashid}/mcp/instance/${app.instance.id}/test-node`, + headers: { + authorization: `Bearer ${token}`, + 'content-type': 'application/json' + }, + payload: { + name: 'test-server', + protocol: 'http', + endpointRoute: '/mcp', + title: 'Test MCP', + version: '1.0.0', + description: 'test' + } + }) + response.statusCode.should.equal(200) + + // Verify listing also works + await login(app) + const listResponse = await app.inject({ + method: 'GET', + url: `/api/v1/teams/${app.team.hashid}/mcp`, + cookies: { sid: TestObjects.aliceSid } + }) + listResponse.statusCode.should.equal(200) + const body = listResponse.json() + body.should.have.property('servers') + body.servers.should.be.an.Array() + }) + + async function login (app) { + if (TestObjects.aliceSid) { + return + } + const response = await app.inject({ + method: 'POST', + url: '/account/login', + payload: { username: 'alice', password: 'aaPassword', remember: false } + }) + TestObjects.aliceSid = response.cookies[0].value + } + }) + + describe('Feature flag disabled', function () { + let app + const TestObjects = {} + + before(async function () { + app = await setup({ + license: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6ImZkNDFmNmRjLTBmM2QtNGFmNy1hNzk0LWIyNWFhNGJmYTliZCIsInZlciI6IjIwMjQtMDMtMDQiLCJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGdXNlIERldmVsb3BtZW50IiwibmJmIjoxNzMwNjc4NDAwLCJleHAiOjIwNzc3NDcyMDAsIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxMCwidGVhbXMiOjEwLCJpbnN0YW5jZXMiOjEwLCJtcXR0Q2xpZW50cyI6NiwidGllciI6ImVudGVycHJpc2UiLCJkZXYiOnRydWUsImlhdCI6MTczMDcyMTEyNH0.02KMRf5kogkpH3HXHVSGprUm0QQFLn21-3QIORhxFgRE9N5DIE8YnTH_f8W_21T6TlYbDUmf4PtWyj120HTM2w', + ai: { enabled: false } + }) + + TestObjects.alicePAT = await app.db.controllers.AccessToken.createPersonalAccessToken( + app.user, + null, + null, + 'alice-pat' + ) + }) + + after(async function () { + await app.close() + }) + + it('should not register the expertPlatformAutomation feature flag when AI is disabled', async function () { + app.config.features.enabled('expertPlatformAutomation').should.equal(false) + }) + + it('should return 404 for POST /api/v1/mcp when feature is disabled', async function () { + const response = await app.inject({ + method: 'POST', + url: '/api/v1/mcp', + headers: { + authorization: `Bearer ${TestObjects.alicePAT.token}` + }, + payload: { jsonrpc: '2.0', method: 'initialize', id: 1 } + }) + response.statusCode.should.equal(404) + }) + }) +}) From c413c162dba8092b9375cfa72c66336ba3203b80 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 23 Jun 2026 12:54:10 +0300 Subject: [PATCH 02/40] fix failing tests by replacing `null` with empty string for PAT creation in MCP server tests --- test/unit/forge/ee/routes/mcp/server_spec.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/unit/forge/ee/routes/mcp/server_spec.js b/test/unit/forge/ee/routes/mcp/server_spec.js index 8e40249c74..b8aae8fe93 100644 --- a/test/unit/forge/ee/routes/mcp/server_spec.js +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -16,7 +16,7 @@ describe('MCP Platform Tools Server', function () { TestObjects.alicePAT = await app.db.controllers.AccessToken.createPersonalAccessToken( app.user, - null, + '', null, 'alice-pat' ) @@ -143,7 +143,7 @@ describe('MCP Platform Tools Server', function () { TestObjects.alicePAT = await app.db.controllers.AccessToken.createPersonalAccessToken( app.user, - null, + '', null, 'alice-pat' ) From 6b598c8f8159f4f94be7e1de5d82e8627afc9f2d Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 23 Jun 2026 13:36:02 +0300 Subject: [PATCH 03/40] fix failing unit test --- test/unit/forge/ee/routes/mcp/server_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/forge/ee/routes/mcp/server_spec.js b/test/unit/forge/ee/routes/mcp/server_spec.js index b8aae8fe93..cc2282dcb5 100644 --- a/test/unit/forge/ee/routes/mcp/server_spec.js +++ b/test/unit/forge/ee/routes/mcp/server_spec.js @@ -154,7 +154,7 @@ describe('MCP Platform Tools Server', function () { }) it('should not register the expertPlatformAutomation feature flag when AI is disabled', async function () { - app.config.features.enabled('expertPlatformAutomation').should.equal(false) + should(app.config.features.enabled('expertPlatformAutomation')).not.equal(true) }) it('should return 404 for POST /api/v1/mcp when feature is disabled', async function () { From f6304069123d0cbe9f6f74a8f11db2f5aa532068 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:33:10 +0200 Subject: [PATCH 04/40] fix(expert): contain render errors per-section and fix resource card crash Add an ErrorBoundary and wrap each answer item in it, so a failure in one section degrades only that section instead of blanking the whole message. Also guard the optional streamable chain in StandardResourceCard that could throw on a null value and take down the message. --- .../expert/components/messages/AiMessage.vue | 17 +++++--- .../messages/components/ErrorBoundary.vue | 39 +++++++++++++++++++ .../resource-cards/StandardResourceCard.vue | 2 +- 3 files changed, 51 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/expert/components/messages/components/ErrorBoundary.vue diff --git a/frontend/src/components/expert/components/messages/AiMessage.vue b/frontend/src/components/expert/components/messages/AiMessage.vue index 0fc8817324..2169233b5c 100644 --- a/frontend/src/components/expert/components/messages/AiMessage.vue +++ b/frontend/src/components/expert/components/messages/AiMessage.vue @@ -1,18 +1,23 @@ + + diff --git a/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue b/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue index 11a0f45c01..52fbf44d95 100644 --- a/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue +++ b/frontend/src/components/expert/components/messages/components/resource-cards/StandardResourceCard.vue @@ -43,7 +43,7 @@ export default { emits: ['streaming-complete'], data () { return { - resourceUrl: this.resource.metadata?.streamable.source || this.resource.streamable.url, + resourceUrl: this.resource.metadata?.streamable?.source || this.resource.url?.streamable, resourceTitle: { ...this.resource.title }, resourceMetadataSource: this.resource.metadata?.source } From 213b352f98d3b15f92f50a866117a35053d2e6f5 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:33:35 +0200 Subject: [PATCH 05/40] feat(expert): grouped clarifying questions UI with cadence control (#407) The Expert can ask 1-4 clarifying questions in a single turn, each rendered as its own single- or multi-select option card; all answers are collected before the turn is submitted. Answered cards can be edited and resubmitted, and a card from a past turn is disabled once a newer message arrives. Adds a follow-up-questions cadence setting (all at once vs one at a time) in the composer settings menu, shipped to the agent via the expert context. --- .../expert/components/ExpertChatInput.vue | 138 +++++++++- .../messages/components/AnswerWrapper.vue | 179 +++++++----- .../components/resources/QuestionsList.vue | 259 ++++++++++++++++++ frontend/src/stores/context.js | 6 +- frontend/src/stores/product-expert.js | 15 +- 5 files changed, 528 insertions(+), 69 deletions(-) create mode 100644 frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index 5d165ee52c..d86f5f3b13 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -17,6 +17,26 @@
+ +
  • + Follow-up questions +

    + When a request needs more detail, choose how the Expert asks for it. +

    + +
  • +
    @@ -65,6 +85,7 @@ import { mapActions, mapState } from 'pinia' import ResizeBar from '../../ResizeBar.vue' +import ToggleButtonGroup from '../../elements/ToggleButtonGroup.vue' import CapabilitiesSelector from './CapabilitiesSelector.vue' import ContextSelector from './context-selection/index.vue' @@ -80,7 +101,8 @@ export default { components: { CapabilitiesSelector, ContextSelector, - ResizeBar + ResizeBar, + ToggleButtonGroup }, inject: { togglePinWithWidth: { @@ -94,6 +116,7 @@ export default { startResize, heightStyle, bindResizer, + setHeight, isResizing: isInputResizing } = useResizingHelper() @@ -101,6 +124,7 @@ export default { startResize, bindResizer, heightStyle, + setHeight, isInputResizing } }, @@ -108,7 +132,10 @@ export default { return { inputText: '', includeSelection: true, - isTextareaFocused: false + isTextareaFocused: false, + // true after we grow the composer to fit loaded content (e.g. an edited question), + // so we can collapse it back to the default height once the message is sent + composerAutoGrown: false } }, computed: { @@ -123,8 +150,24 @@ export default { 'isInsightsAgent', 'hasSelectedCapabilities', 'hasMessages', - 'isWaitingForResponse' + 'isWaitingForResponse', + 'pendingInput', + 'questionCadence' ]), + questionCadenceButtons () { + return [ + { title: 'All at once', value: 'all' }, + { title: 'One at a time', value: 'one' } + ] + }, + questionCadenceWrapper: { + get () { + return this.questionCadence + }, + set (value) { + this.setQuestionCadence(value) + } + }, isInputDisabled () { if (this.isSessionExpired) return true if (this.isWaitingForResponse) return true @@ -148,6 +191,21 @@ export default { return this.isImmersiveDevice || this.isImmersiveInstance } }, + watch: { + pendingInput (text) { + if (text) { + this.inputText = text + this.setPendingInput('') + this.$nextTick(() => { + this.$refs.textarea.focus() + // Grow the composer so loaded content (e.g. an edited question) is readable, + // instead of being crammed into the default-height box. The CSS max-height + // (40vh) caps it; short content stays near the minimum. + this.growComposerToContent() + }) + } + } + }, mounted () { this.bindResizer({ component: this.$refs.resizeTarget, @@ -158,7 +216,7 @@ export default { }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence']), async handleSend () { if (!this.canSend) return @@ -181,6 +239,38 @@ export default { .catch(e => e) this.inputText = '' + // Collapse the composer back to its default height if we had grown it + // (180 matches the CSS min-height of .ff-expert-input). + if (this.composerAutoGrown) { + this.setHeight(180) + this.composerAutoGrown = false + } + }, + growComposerToContent () { + const textarea = this.$refs.textarea + const container = this.$refs.resizeTarget + if (!textarea || !container) return + // Height of everything in the composer that isn't the textarea — the action-buttons + // row, the Send/context row, the container's padding, gaps and border. Only the + // textarea flex-grows, so this difference is invariant to the current height. Measure + // it at runtime (before collapsing the textarea below) rather than hard-coding it, so + // it stays correct if those rows change. + const chromeHeight = container.offsetHeight - textarea.clientHeight + // The textarea has flex: 1, so it stretches to fill the container. scrollHeight is + // floored at the element's client height, so reading it while stretched returns the + // current (possibly already-grown) box height rather than the text's true height — + // which would ratchet the composer taller on every call. Briefly take the textarea + // out of the flex stretch and collapse it so scrollHeight reflects only the content, + // then restore the inline styles. + const prevFlex = textarea.style.flex + const prevHeight = textarea.style.height + textarea.style.flex = '0 0 auto' + textarea.style.height = '0px' + const contentHeight = textarea.scrollHeight + textarea.style.flex = prevFlex + textarea.style.height = prevHeight + this.setHeight(contentHeight + chromeHeight) + this.composerAutoGrown = true }, handleStop () { this.$emit('stop') @@ -232,6 +322,7 @@ export default { .right-buttons { display: flex; gap: 0.5rem; + align-items: center; } button { @@ -350,3 +441,42 @@ button { } } + + + diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index ce26544b8c..e14708a7ad 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -1,71 +1,91 @@ @@ -76,12 +96,14 @@ import { mapActions, mapState } from 'pinia' import useTimerHelper from '../../../../../composables/TimerHelper.js' import AnswerBadge from './AnswerBadge.vue' +import ErrorBoundary from './ErrorBoundary.vue' import GuideHeader from './GuideHeader.vue' import MessageBubble from './MessageBubble.vue' import FlowsList from './resources/FlowsList.vue' import GuideStepsList from './resources/GuideStepsList.vue' import IssuesList from './resources/IssuesList.vue' import PackagesList from './resources/PackagesList.vue' +import QuestionsList from './resources/QuestionsList.vue' import ResourcesList from './resources/ResourcesList.vue' import RichContent from './resources/RichContent.vue' import SuggestionsList from './resources/SuggestionsList.vue' @@ -97,7 +119,9 @@ export default { PackagesList, FlowsList, AnswerBadge, + ErrorBoundary, ResourcesList, + QuestionsList, GuideStepsList, MessageBubble, GuideHeader, @@ -126,10 +150,21 @@ export default { }, computed: { ...mapState(useProductAssistantStore, ['supportedActions']), - ...mapState(useProductExpertStore, ['agentMode']), + ...mapState(useProductExpertStore, ['agentMode', 'isWaitingForResponse', 'messages']), + isLatestMessage () { + const msgs = this.messages || [] + return msgs.length > 0 && msgs[msgs.length - 1]?._uuid === this.messageUuid + }, + interactionDisabled () { + // Disable the questions card while a response is in flight, and once the turn + // has passed — i.e. any message has arrived after this one — so a stale card from + // an earlier turn can no longer be answered. + return this.isWaitingForResponse || !this.isLatestMessage + }, hasGuideHeader () { - // chat answers contain generic titles, they don't need to be displayed - return !!(this.answer.title && !this.isChatAnswer) + // chat answers contain generic titles, they don't need to be displayed. + // questions answers carry no guide title either. + return !!(this.answer.title && !this.isChatAnswer && !this.isQuestionsAnswer) }, hasGuideSteps () { return Object.hasOwnProperty.call(this.answer, 'steps') && this.answer.steps.length > 0 @@ -152,9 +187,15 @@ export default { hasPlainContent () { return this.answer.content && this.answer.content.length > 0 }, + hasQuestions () { + return Array.isArray(this.answer.questions) && this.answer.questions.length > 0 + }, isChatAnswer () { return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' }, + isQuestionsAnswer () { + return this.answer.kind === 'questions' + }, isEditorContext () { // In editor context, the route name includes 'editor' return this.$route?.name?.includes('editor') || false @@ -215,6 +256,13 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowQuestionsList () { + const key = 'questions-list' + if (!this.componentStreamingOrder.includes(key)) return false + if (!this.hasQuestions) return false + if (this.componentStreamingOrder.indexOf(key) === 0) return true + return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) + }, shouldStream () { return !this.answer._streamed } @@ -250,7 +298,7 @@ export default { } }, methods: { - ...mapActions(useProductExpertStore, ['updateAnswerStreamedState']), + ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput']), buildStreamingOrder () { // order matters // this is where the decision of the streaming order of components is decided @@ -263,12 +311,19 @@ export default { if (this.hasNodePackages) this.componentStreamingOrder.push('packages-list') if (this.hasIssues) this.componentStreamingOrder.push('issues-list') if (this.hasSuggestions) this.componentStreamingOrder.push('suggestions-list') + if (this.hasQuestions) this.componentStreamingOrder.push('questions-list') }, async onComponentComplete (key) { if (!this.shouldStream) await this.waitFor(200) this.streamedComponents.push(key) }, + onQuestionsSubmit (text) { + this.handleQuery({ query: text }) + }, + onQuestionsEdit (text) { + this.setPendingInput(text) + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue new file mode 100644 index 0000000000..c9c53932a0 --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index cc89ea8c69..c29925a01e 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -57,7 +57,8 @@ export const useContextStore = defineStore('context', { pageName: null, rawRoute: {}, selectedNodes: null, - scope: 'ff-app' + scope: 'ff-app', + questionCadence: useProductExpertStore().questionCadence } } @@ -106,7 +107,8 @@ export const useContextStore = defineStore('context', { nodeRedVersion: assistantStore.nodeRedVersion, rawRoute, selectedNodes, - scope + scope, + questionCadence: useProductExpertStore().questionCadence } } }, diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index a1925a0ed6..1aea56c449 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -29,7 +29,9 @@ export const useProductExpertStore = defineStore('product-expert', { agentMode: SUPPORT_AGENT, // support-agent or insights-agent loadingVariant: SUPPORT_AGENT, shouldWakeUpAssistant: false, + questionCadence: 'all', // 'all' = ask every clarifying question at once, 'one' = one at a time inFlightUpdates: [], + pendingInput: '', _seenTransactionIds: new Map() }), getters: { @@ -176,6 +178,9 @@ export const useProductExpertStore = defineStore('product-expert', { .then(() => { this.loadingVariant = this.agentMode }) } }, + setPendingInput (text) { + this.pendingInput = text + }, async handleQuery ({ query }) { const agentStore = this._agentStore @@ -499,6 +504,14 @@ export const useProductExpertStore = defineStore('product-expert', { this.agentMode = mode this.loadingVariant = mode }, + /** + * Sets how clarifying questions are asked: all at once or one at a time. + * @param {'all' | 'one'} cadence + */ + setQuestionCadence (cadence) { + if (!['all', 'one'].includes(cadence)) return + this.questionCadence = cadence + }, /** * Adds a system message to the application's message store. * @@ -1063,7 +1076,7 @@ export const useProductExpertStore = defineStore('product-expert', { } }, persist: { - pick: ['shouldWakeUpAssistant'], + pick: ['shouldWakeUpAssistant', 'questionCadence'], storage: localStorage } }) From 500604711f6c02a7b8426db7cf47a34788489ffe Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 19:37:39 +0200 Subject: [PATCH 06/40] =?UTF-8?q?feat(expert):=20plan=20mode=20=E2=80=94?= =?UTF-8?q?=20propose=20a=20plan=20before=20acting=20(#408,=20#409)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an always-visible Plan mode toggle to the composer. When enabled, the Expert proposes a plan instead of making changes, rendered as a plan card with Approve, Edit, Request changes and Reject actions: - Approve exits plan mode and proceeds with the plan. - Edit loads the plan markdown into the composer for direct editing. - Request changes focuses an empty composer to describe a change in words. - Reject abandons the plan. The plan card renders its markdown through RichContent (passing the message and answer uuids it requires), and reuses the composer's pending-input and auto-grow behaviour. Plan mode and the approval signal are shipped to the agent via the expert context. --- .../expert/components/ExpertChatInput.vue | 95 +++++++++++++++- .../expert/components/chips/DefaultChip.vue | 24 +++- .../messages/components/AnswerWrapper.vue | 71 +++++++++++- .../components/resources/PlanCard.vue | 103 ++++++++++++++++++ frontend/src/stores/context.js | 6 +- frontend/src/stores/product-expert.js | 40 +++++-- 6 files changed, 315 insertions(+), 24 deletions(-) create mode 100644 frontend/src/components/expert/components/messages/components/resources/PlanCard.vue diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index d86f5f3b13..f7889ce964 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -16,6 +16,20 @@ Start over
    + + + { this.$refs.textarea.focus() - // Grow the composer so loaded content (e.g. an edited question) is readable, + // Grow the composer so loaded content (e.g. an edited plan) is readable, // instead of being crammed into the default-height box. The CSS max-height // (40vh) caps it; short content stays near the minimum. this.growComposerToContent() }) } + }, + planChangeRequest () { + // The plan card's "Request changes" action: focus an empty composer and show + // the change hint, so the user can describe a change in their own words. + this.inputText = '' + this.requestingPlanChange = true + this.$nextTick(() => { + this.$refs.textarea.focus() + }) + }, + composerReset () { + // A plan was loaded into the composer (via "Edit manually") then approved or + // rejected without sending; clear the stale text and collapse the grown box. + this.inputText = '' + this.requestingPlanChange = false + if (this.composerAutoGrown) { + this.setHeight(180) + this.composerAutoGrown = false + } + }, + inputText (value) { + // Clear the plan-change hint once the user starts typing their own text. + if (value && this.requestingPlanChange) { + this.requestingPlanChange = false + } } }, mounted () { @@ -216,7 +266,7 @@ export default { }, methods: { ...mapActions(useProductAssistantStore, ['resetContextSelection']), - ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence']), + ...mapActions(useProductExpertStore, ['startOver', 'handleQuery', 'handleMessageResponse', 'setPendingInput', 'setQuestionCadence', 'setPlanMode']), async handleSend () { if (!this.canSend) return @@ -239,6 +289,7 @@ export default { .catch(e => e) this.inputText = '' + this.requestingPlanChange = false // Collapse the composer back to its default height if we had grown it // (180 matches the CSS min-height of .ff-expert-input). if (this.composerAutoGrown) { @@ -325,6 +376,42 @@ export default { align-items: center; } +// Reuses the shared DefaultChip for its bg, border, active state and theming; the only +// styling here is sizing the toggle switch in the #icon slot, since the switch has no size +// prop. It is a visual indicator only (pointer-events disabled); the chip handles the click. +.plan-mode-chip { + // DefaultChip's separator is a warning-yellow in the inactive state (intended for the + // Selection chip); neutralise it here for both states so it reads as a plain divider. + :deep(.separator), + &.active :deep(.separator) { + background: var(--ff-color-border); + } + + // DefaultChip's .text padding is asymmetric (less on the left) AND the chip adds a 5px + // flex gap between the text box and the divider, so the label sits left of centre. + // Equalise the padding and subtract the gap from the right so "Plan mode" has the same + // visual space on both sides of the divider cell. + :deep(.text) { + padding-left: 0.5rem; + padding-right: calc(0.5rem - 5px); + } + + :deep(.ff-toggle-switch) { + --ff-toggle-width: 30px; + --ff-toggle-translate: 12px; + height: 18px; + pointer-events: none; + flex-shrink: 0; + } + + :deep(.ff-toggle-switch-button) { + height: 14px; + width: 14px; + left: 2px; + bottom: 2px; + } +} + button { padding: 0.5rem 0.75rem; // py-2 px-3 border-radius: 9999px; // rounded-full diff --git a/frontend/src/components/expert/components/chips/DefaultChip.vue b/frontend/src/components/expert/components/chips/DefaultChip.vue index 057ad81615..5e6df33722 100644 --- a/frontend/src/components/expert/components/chips/DefaultChip.vue +++ b/frontend/src/components/expert/components/chips/DefaultChip.vue @@ -1,5 +1,5 @@ @@ -41,11 +43,20 @@ export default { type: String, required: false, default: '' + }, + disabled: { + type: Boolean, + required: false, + default: false } }, emits: ['toggle'], methods: { - pluralize + pluralize, + onClick () { + if (this.disabled) return + this.$emit('toggle') + } } } @@ -62,6 +73,11 @@ export default { transition: 0.3s ease-in-out; white-space: nowrap; + &.disabled { + opacity: 0.5; + cursor: not-allowed; + } + &.active { background: var(--ff-color-accent-surface); border: 1px solid var(--ff-color-accent-light); diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index e14708a7ad..e848b05b21 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -1,6 +1,6 @@ @@ -103,6 +119,7 @@ import FlowsList from './resources/FlowsList.vue' import GuideStepsList from './resources/GuideStepsList.vue' import IssuesList from './resources/IssuesList.vue' import PackagesList from './resources/PackagesList.vue' +import PlanCard from './resources/PlanCard.vue' import QuestionsList from './resources/QuestionsList.vue' import ResourcesList from './resources/ResourcesList.vue' import RichContent from './resources/RichContent.vue' @@ -117,6 +134,7 @@ export default { SuggestionsList, RichContent, PackagesList, + PlanCard, FlowsList, AnswerBadge, ErrorBoundary, @@ -156,7 +174,7 @@ export default { return msgs.length > 0 && msgs[msgs.length - 1]?._uuid === this.messageUuid }, interactionDisabled () { - // Disable the questions card while a response is in flight, and once the turn + // Disable the question/plan cards while a response is in flight, and once the turn // has passed — i.e. any message has arrived after this one — so a stale card from // an earlier turn can no longer be answered. return this.isWaitingForResponse || !this.isLatestMessage @@ -164,7 +182,8 @@ export default { hasGuideHeader () { // chat answers contain generic titles, they don't need to be displayed. // questions answers carry no guide title either. - return !!(this.answer.title && !this.isChatAnswer && !this.isQuestionsAnswer) + // plan answers carry their heading inside their Markdown content, not a title. + return !!(this.answer.title && !this.isChatAnswer && !this.isQuestionsAnswer && !this.isPlanAnswer) }, hasGuideSteps () { return Object.hasOwnProperty.call(this.answer, 'steps') && this.answer.steps.length > 0 @@ -185,17 +204,25 @@ export default { return this.answer.issues && this.answer.issues.length > 0 }, hasPlainContent () { - return this.answer.content && this.answer.content.length > 0 + // A plan keeps its markdown in content, but the PlanCard renders it; do not + // also render it as plain rich-content above the card. + return !this.isPlanAnswer && this.answer.content && this.answer.content.length > 0 }, hasQuestions () { return Array.isArray(this.answer.questions) && this.answer.questions.length > 0 }, + hasPlan () { + return this.isPlanAnswer && typeof this.answer.content === 'string' && this.answer.content.length > 0 + }, isChatAnswer () { return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' }, isQuestionsAnswer () { return this.answer.kind === 'questions' }, + isPlanAnswer () { + return this.answer.kind === 'plan' + }, isEditorContext () { // In editor context, the route name includes 'editor' return this.$route?.name?.includes('editor') || false @@ -263,6 +290,13 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowPlanList () { + const key = 'plan-list' + if (!this.componentStreamingOrder.includes(key)) return false + if (!this.hasPlan) return false + if (this.componentStreamingOrder.indexOf(key) === 0) return true + return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) + }, shouldStream () { return !this.answer._streamed } @@ -298,7 +332,7 @@ export default { } }, methods: { - ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput']), + ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode']), buildStreamingOrder () { // order matters // this is where the decision of the streaming order of components is decided @@ -312,6 +346,7 @@ export default { if (this.hasIssues) this.componentStreamingOrder.push('issues-list') if (this.hasSuggestions) this.componentStreamingOrder.push('suggestions-list') if (this.hasQuestions) this.componentStreamingOrder.push('questions-list') + if (this.hasPlan) this.componentStreamingOrder.push('plan-list') }, async onComponentComplete (key) { if (!this.shouldStream) await this.waitFor(200) @@ -324,6 +359,32 @@ export default { onQuestionsEdit (text) { this.setPendingInput(text) }, + onPlanApprove () { + // Approving exits plan mode. Plan mode is strictly read-only, so we turn it off + // here rather than punching a write override through an active plan mode; the build + // then runs as a normal acting turn, and follow-up turns keep building instead of + // dropping back into planning. + this.setPlanMode(false) + // Clear the composer in case the plan was loaded into it via "Edit manually" + // and then approved without sending; the loaded text is now stale. + this.resetComposer() + this.handleQuery({ query: 'Approved. Proceed with the plan.' }) + }, + onPlanEditManual () { + // Load the plan markdown into the message box so the user can edit it directly, + // then send it back for the agent to re-propose as an updated plan to approve. + this.setPendingInput(this.answer.content) + }, + onPlanRequestChanges () { + // Focus an empty composer so the user can describe a change in their own words; + // the agent folds it in and re-proposes an updated plan to approve. + this.requestPlanChange() + }, + onPlanReject () { + // Drop any plan text loaded into the composer via "Edit manually". + this.resetComposer() + this.handleQuery({ query: 'I do not want to proceed with this plan.' }) + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/resources/PlanCard.vue b/frontend/src/components/expert/components/messages/components/resources/PlanCard.vue new file mode 100644 index 0000000000..f6ef580afc --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/resources/PlanCard.vue @@ -0,0 +1,103 @@ + + + + + diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index c29925a01e..d22eee5f9f 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -58,7 +58,8 @@ export const useContextStore = defineStore('context', { rawRoute: {}, selectedNodes: null, scope: 'ff-app', - questionCadence: useProductExpertStore().questionCadence + questionCadence: useProductExpertStore().questionCadence, + planMode: useProductExpertStore().planMode } } @@ -108,7 +109,8 @@ export const useContextStore = defineStore('context', { rawRoute, selectedNodes, scope, - questionCadence: useProductExpertStore().questionCadence + questionCadence: useProductExpertStore().questionCadence, + planMode: useProductExpertStore().planMode } } }, diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index 1aea56c449..da26708210 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -30,8 +30,15 @@ export const useProductExpertStore = defineStore('product-expert', { loadingVariant: SUPPORT_AGENT, shouldWakeUpAssistant: false, questionCadence: 'all', // 'all' = ask every clarifying question at once, 'one' = one at a time + planMode: false, inFlightUpdates: [], pendingInput: '', + // Incremented to ask the chat composer to focus an empty input for a plan-change + // request (the plan card's "Request changes" action). The composer watches it. + planChangeRequest: 0, + // Incremented to ask the chat composer to clear itself (e.g. a plan was loaded via + // "Edit manually" then approved/rejected without sending). The composer watches it. + composerReset: 0, _seenTransactionIds: new Map() }), getters: { @@ -181,7 +188,18 @@ export const useProductExpertStore = defineStore('product-expert', { setPendingInput (text) { this.pendingInput = text }, - async handleQuery ({ query }) { + requestPlanChange () { + // Signal the chat composer to focus an empty input so the user can describe + // a change to a proposed plan. Bumping a counter lets the composer react each + // time, including repeated requests. + this.planChangeRequest++ + }, + resetComposer () { + // Signal the chat composer to clear its input. Bumping a counter lets the + // composer react each time, including repeated resets. + this.composerReset++ + }, + async handleQuery ({ query, contextOverrides }) { const agentStore = this._agentStore // Auto-initialize session ID if not set @@ -200,7 +218,7 @@ export const useProductExpertStore = defineStore('product-expert', { agentStore.abortController = markRaw(new AbortController()) try { - return await this.sendQuery({ query }) + return await this.sendQuery({ query, contextOverrides }) } catch (error) { if (error.name === 'AbortError' || error.name === 'CanceledError') { // User canceled request @@ -217,20 +235,21 @@ export const useProductExpertStore = defineStore('product-expert', { agentStore.abortController = null } }, - sendQuery ({ query }) { + sendQuery ({ query, contextOverrides }) { if (this.shouldUseMqtt) { - return this.sendMqttQuery({ query }) + return this.sendMqttQuery({ query, contextOverrides }) } else { - return this.sendHttpQuery({ query }) + return this.sendHttpQuery({ query, contextOverrides }) } }, - async sendHttpQuery ({ query }) { + async sendHttpQuery ({ query, contextOverrides }) { const agentStore = this._agentStore const payload = { query, context: { ...useContextStore().expert, - agent: this.agentMode + agent: this.agentMode, + ...(contextOverrides || {}) }, sessionId: agentStore.sessionId, abortController: agentStore.abortController @@ -242,7 +261,7 @@ export const useProductExpertStore = defineStore('product-expert', { return expertApi.chat(payload) }, - async sendMqttQuery ({ query } = {}) { + async sendMqttQuery ({ query, contextOverrides } = {}) { const servicesOrchestrator = getAppOrchestrator() const mqttService = servicesOrchestrator.$serviceInstances.mqtt const mqttTopicHelper = useMqttExpertTopicHelper() @@ -512,6 +531,9 @@ export const useProductExpertStore = defineStore('product-expert', { if (!['all', 'one'].includes(cadence)) return this.questionCadence = cadence }, + setPlanMode (enabled) { + this.planMode = !!enabled + }, /** * Adds a system message to the application's message store. * @@ -1076,7 +1098,7 @@ export const useProductExpertStore = defineStore('product-expert', { } }, persist: { - pick: ['shouldWakeUpAssistant', 'questionCadence'], + pick: ['shouldWakeUpAssistant', 'questionCadence', 'planMode'], storage: localStorage } }) From 25f3c2cea76b4322d0cc62577c0881c6db851ad3 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Mon, 29 Jun 2026 20:46:31 +0200 Subject: [PATCH 07/40] feat(expert): scope plan mode to immersive editor only Plan mode is only meaningful inside the instance/device editor for now, so gate the composer toggle on immersive mode and force the persisted planMode off whenever the user is outside immersive (including on load), preventing a stale value from being sent in non-immersive contexts. --- .../expert/components/ExpertChatInput.vue | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index f7889ce964..f37198ed3f 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -16,8 +16,11 @@ Start over
    + Date: Tue, 30 Jun 2026 14:34:05 +0200 Subject: [PATCH 08/40] refactor(expert): address review feedback on clarifying-questions UI - Guard the optional streamable chain in FlowResourceCard directly instead of relying on a render boundary to mask the throw. Reduce ErrorBoundary to a single last-resort backstop per answer item in AiMessage; drop the per-section boundary wrappers in AnswerWrapper. - Rewrite QuestionsList on top of the existing ff-radio-group (single-select) and ff-checkbox (multi-select) components so options look like standard, clickable form controls and stay consistent with the rest of the app. - Replace the imperative growComposerToContent DOM measuring with CSS field-sizing on the textarea; drop the manual reflows and the auto-grown flag. The composer auto-sizes to content and pins to an explicit height only after a drag-resize. --- .../expert/components/ExpertChatInput.vue | 65 +++---- .../messages/components/AnswerWrapper.vue | 145 +++++++--------- .../messages/components/ErrorBoundary.vue | 8 +- .../resource-cards/FlowResourceCard.vue | 28 ++- .../components/resources/QuestionsList.vue | 164 +++++------------- 5 files changed, 162 insertions(+), 248 deletions(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index d86f5f3b13..5610d57a90 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -1,9 +1,9 @@ @@ -96,7 +87,6 @@ import { mapActions, mapState } from 'pinia' import useTimerHelper from '../../../../../composables/TimerHelper.js' import AnswerBadge from './AnswerBadge.vue' -import ErrorBoundary from './ErrorBoundary.vue' import GuideHeader from './GuideHeader.vue' import MessageBubble from './MessageBubble.vue' import FlowsList from './resources/FlowsList.vue' @@ -119,7 +109,6 @@ export default { PackagesList, FlowsList, AnswerBadge, - ErrorBoundary, ResourcesList, QuestionsList, GuideStepsList, diff --git a/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue b/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue index 5afb1a90b6..30797769c7 100644 --- a/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue +++ b/frontend/src/components/expert/components/messages/components/ErrorBoundary.vue @@ -15,9 +15,11 @@ export default { } }, errorCaptured (err) { - // A descendant threw during render/lifecycle (e.g. an answer with a malformed - // resource missing its url/metadata). Contain it here so a single bad answer - // degrades to a small fallback instead of blanking the entire chat message. + // Last-resort backstop: known throws are guarded at their source (e.g. the optional + // streamable chains in the resource cards). This only catches a genuinely unexpected + // render/lifecycle failure so one bad answer item degrades to a small fallback instead + // of an uncaught error tearing down the whole chat. It is intentionally used once, per + // answer item, in AiMessage — not as a per-section wrapper. // Returning false stops the error from propagating further up the tree. // eslint-disable-next-line no-console console.error('[Expert] render error contained by ErrorBoundary:', err) diff --git a/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue b/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue index c39a21aa78..e324c8fe06 100644 --- a/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue +++ b/frontend/src/components/expert/components/messages/components/resource-cards/FlowResourceCard.vue @@ -29,18 +29,18 @@ Import
    -
    +
    -
    - +
    +
    @@ -81,9 +81,18 @@ export default { }, computed: { ...mapState(useProductExpertStore, ['canImportFlows']), + // The streamable payload is optional and can be malformed (missing entirely, or + // present without flows/category). Read it defensively so a bad value renders as + // "nothing to show" instead of throwing and taking down the whole message. + flowData () { + return this.flowMetadata?.streamable?.flows ?? null + }, + flowCategory () { + return this.flowMetadata?.streamable?.category ?? null + }, flowsJson () { - if (!this.flowMetadata) return '' - return JSON.stringify(this.flowMetadata.streamable.flows, null, 2) + if (!this.flowData) return '' + return JSON.stringify(this.flowData, null, 2) } }, watch: { @@ -95,6 +104,13 @@ export default { } } }, + mounted () { + // The category line is what normally drives completeStreaming. When there's no + // category to stream, signal completion here so the parent streaming list still advances. + if (!this.flowCategory) { + this.$nextTick(() => this.completeStreaming()) + } + }, methods: { ...mapActions(useProductAssistantStore, ['sendFlowsToImport']), importFlows () { diff --git a/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue index c9c53932a0..b9ed29d708 100644 --- a/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue +++ b/frontend/src/components/expert/components/messages/components/resources/QuestionsList.vue @@ -5,29 +5,26 @@

    {{ q.question }}

    {{ q.multiSelect ? 'Select all that apply' : 'Select one' }} -
    - +
    @@ -53,11 +50,8 @@ - - From b62528c6db172484e73afb7a38f684afdd88ce7c Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 30 Jun 2026 15:05:16 +0200 Subject: [PATCH 10/40] feat(expert): move composer settings into a dialog Replace the composer kebab menu with a settings gear that opens an ff-dialog. The follow-up-questions cadence control now lives in the dialog as an ff-radio-group, with a FormHeading per section so the panel can grow as more settings are added. --- .../expert/components/ExpertChatInput.vue | 120 ++++++++++-------- 1 file changed, 68 insertions(+), 52 deletions(-) diff --git a/frontend/src/components/expert/components/ExpertChatInput.vue b/frontend/src/components/expert/components/ExpertChatInput.vue index 5610d57a90..f28d6ed49f 100644 --- a/frontend/src/components/expert/components/ExpertChatInput.vue +++ b/frontend/src/components/expert/components/ExpertChatInput.vue @@ -17,26 +17,17 @@
    - -
  • - Follow-up questions -

    - When a request needs more detail, choose how the Expert asks for it. -

    - -
  • -
    + +
    @@ -78,14 +69,36 @@
    + + +
    +
    + Follow-up questions +

    When a request needs more detail, choose how the Expert asks for it.

    + +
    +
    +
    + + diff --git a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue index 82201c1740..9011b7a687 100644 --- a/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue +++ b/frontend/src/components/expert/components/messages/components/AnswerWrapper.vue @@ -92,6 +92,19 @@ @reject="onPlanReject" @streaming-complete="onComponentComplete('plan-list')" /> + + @@ -113,6 +126,7 @@ import QuestionsList from './resources/QuestionsList.vue' import ResourcesList from './resources/ResourcesList.vue' import RichContent from './resources/RichContent.vue' import SuggestionsList from './resources/SuggestionsList.vue' +import ToolApprovalCard from './resources/ToolApprovalCard.vue' import { useProductAssistantStore } from '@/stores/product-assistant.js' import { useProductExpertStore } from '@/stores/product-expert.js' @@ -131,7 +145,8 @@ export default { GuideStepsList, MessageBubble, GuideHeader, - IssuesList + IssuesList, + ToolApprovalCard }, props: { answer: { @@ -202,6 +217,9 @@ export default { hasPlan () { return this.isPlanAnswer && typeof this.answer.content === 'string' && this.answer.content.length > 0 }, + hasToolApproval () { + return this.answer.kind === 'tool-approval' && !!this.answer.id + }, isChatAnswer () { return !Object.hasOwnProperty.call(this.answer, 'kind') || this.answer.kind === 'chat' }, @@ -285,6 +303,13 @@ export default { if (this.componentStreamingOrder.indexOf(key) === 0) return true return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) }, + shouldShowToolApproval () { + const key = 'tool-approval-card' + if (!this.componentStreamingOrder.includes(key)) return false + if (!this.hasToolApproval) return false + if (this.componentStreamingOrder.indexOf(key) === 0) return true + return this.streamedComponents.length >= this.componentStreamingOrder.indexOf(key) + }, shouldStream () { return !this.answer._streamed } @@ -320,7 +345,7 @@ export default { } }, methods: { - ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode']), + ...mapActions(useProductExpertStore, ['updateAnswerStreamedState', 'handleQuery', 'setPendingInput', 'requestPlanChange', 'resetComposer', 'setPlanMode', 'resolveToolApproval']), buildStreamingOrder () { // order matters // this is where the decision of the streaming order of components is decided @@ -335,6 +360,7 @@ export default { if (this.hasSuggestions) this.componentStreamingOrder.push('suggestions-list') if (this.hasQuestions) this.componentStreamingOrder.push('questions-list') if (this.hasPlan) this.componentStreamingOrder.push('plan-list') + if (this.hasToolApproval) this.componentStreamingOrder.push('tool-approval-card') }, async onComponentComplete (key) { if (!this.shouldStream) await this.waitFor(200) @@ -373,6 +399,15 @@ export default { this.resetComposer() this.handleQuery({ query: 'I do not want to proceed with this plan.' }) }, + onToolApprove () { + this.resolveToolApproval({ id: this.answer.id, approved: true, always: false }) + }, + onToolAllowAlways () { + this.resolveToolApproval({ id: this.answer.id, approved: true, always: true }) + }, + onToolDeny () { + this.resolveToolApproval({ id: this.answer.id, approved: false, always: false }) + }, handleClick (e) { const target = e.target // - Must be in the immersive editor diff --git a/frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue b/frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue new file mode 100644 index 0000000000..9ba1f7034a --- /dev/null +++ b/frontend/src/components/expert/components/messages/components/resources/ToolApprovalCard.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/frontend/src/stores/context.js b/frontend/src/stores/context.js index d22eee5f9f..7185e22eba 100644 --- a/frontend/src/stores/context.js +++ b/frontend/src/stores/context.js @@ -1,7 +1,9 @@ import { defineStore } from 'pinia' import teamApi from '../api/team.js' +import { hasAMinimumTeamRoleOf } from '../composables/Permissions.js' import product from '../services/product.js' +import { Roles } from '../utils/roles.js' import { useAccountAuthStore } from './account-auth.js' import { useProductAssistantStore } from './product-assistant.js' @@ -110,7 +112,12 @@ export const useContextStore = defineStore('context', { selectedNodes, scope, questionCadence: useProductExpertStore().questionCadence, - planMode: useProductExpertStore().planMode + planMode: useProductExpertStore().planMode, + // Human-in-the-loop tool permissions (#421). The agent gates each + // flow-building tool call against this map; canUseWriteTools drives + // role inheritance (fail-closed) for write/delete tools. + toolPermissions: assistantStore.resolvedToolPermissions, + canUseWriteTools: hasAMinimumTeamRoleOf(Roles.Member, state.teamMembership) } } }, diff --git a/frontend/src/stores/product-assistant.js b/frontend/src/stores/product-assistant.js index 0eae819916..8c9b4a2bbe 100644 --- a/frontend/src/stores/product-assistant.js +++ b/frontend/src/stores/product-assistant.js @@ -6,6 +6,27 @@ import { useContextStore } from '@/stores/context.js' const MAX_DEBUG_LOG_ENTRIES = 100 // maximum number of debug log entries to keep +// --- Expert tool permissions (human-in-the-loop, #421) ----------------------- +// Pending tool-approval resolvers, keyed by approval id. Kept at module scope +// (not in store state) so the Map and the Promise resolvers it holds are never +// wrapped in a reactive proxy, which would break their internals. +const pendingToolApprovals = new Map() + +const TOOL_POLICIES = ['allow', 'ask', 'deny'] +const isToolPolicy = (p) => TOOL_POLICIES.includes(p) +const TOOL_CLASSES = ['read', 'write', 'delete'] +// Fail-safe default when a class has no configured default: read allows, the rest ask. +const fallbackForToolClass = (cls) => (cls === 'read' ? 'allow' : 'ask') + +// Derive a tool's permission class from its catalog entry. Read tools view only; +// delete tools are destructive writes; everything else that changes flows is write. +export const classOf = (entry) => { + if (!entry) return 'write' + if (entry.toolClass === 'read') return 'read' + if (entry.toolClass === 'delete' || entry.destructive === true) return 'delete' + return 'write' +} + const eventsRegistry = { 'editor:open': { nodeRedEvent: 'editor:open', // this is the Node-RED event @@ -158,7 +179,13 @@ export const useProductAssistantStore = defineStore('product-assistant', { // internally; external consumers should read this.debugLog (the getter). debugLogEntries: [], editorState: { ...buildInitialEditorState() }, - pendingRequests: new Map() // key is transactionId, value is { resolve, reject, timeout, timestamp, type, action, params } + pendingRequests: new Map(), // key is transactionId, value is { resolve, reject, timeout, timestamp, type, action, params } + // Expert tool permissions (HITL, #421). The catalog + hash are refreshed from + // the agent; defaults + preferences are the user's choices (persisted below). + toolCatalog: [], + toolCatalogHash: null, + toolDefaults: { read: 'allow', write: 'ask', delete: 'ask' }, + toolPreferences: {} }), getters: { isImmersiveInstance: () => { @@ -260,6 +287,64 @@ export const useProductAssistantStore = defineStore('product-assistant', { // NOTE: this is achieved via dynamic event registration for 'flows:loaded' and 'runtime-state' events, // which requires nr-assistant version 0.10.1 or later. return state.editorState?.flowsLoaded || state.editorState?.runtimeState?.state === 'start' + }, + // --- Expert tool permissions (HITL, #421) --- + /** The standing default for a tool class ('read'|'write'|'delete'). */ + defaultForToolClass: (state) => (cls) => { + const d = state.toolDefaults?.[cls] + return isToolPolicy(d) ? d : fallbackForToolClass(cls) + }, + /** + * Effective policy for a catalog key. An explicit per-tool preference always + * wins; otherwise the standing default for the tool's class applies. + */ + toolPolicyFor: (state) => (key) => { + const explicit = state.toolPreferences[key] + if (isToolPolicy(explicit)) return explicit + const entry = state.toolCatalog.find(t => t.key === key) + const cls = classOf(entry) + const d = state.toolDefaults?.[cls] + return isToolPolicy(d) ? d : fallbackForToolClass(cls) + }, + /** + * The resolved permission map sent to the agent in the chat context: + * { defaults, tools: { [key]: 'allow'|'ask'|'deny' } }. + */ + resolvedToolPermissions: (state) => { + const defaults = {} + for (const cls of TOOL_CLASSES) { + defaults[cls] = isToolPolicy(state.toolDefaults?.[cls]) ? state.toolDefaults[cls] : fallbackForToolClass(cls) + } + const tools = {} + for (const t of state.toolCatalog) { + const explicit = state.toolPreferences[t.key] + tools[t.key] = isToolPolicy(explicit) ? explicit : defaults[classOf(t)] + } + return { defaults, tools } + }, + /** + * Availability of a tool against the instance's installed nr-assistant version + * (from `_meta.assistantMinVersion` / `assistantMaxVersion` on each entry). + * Returns { status: 'available'|'requires-update'|'deprecated', deprecated, requiredVersion }. + * - requires-update: instance is below the tool's min version (update to enable). + * - deprecated: instance is past the tool's max version (a newer variant supersedes it). + * - available + deprecated flag: in range, but a max is set so an update will supersede it. + */ + toolAvailabilityFor: (state) => (entry) => { + const version = state.version + const min = entry?.minVersion || null + const max = entry?.maxVersion || null + if (!version || !SemVer.valid(version)) { + // Without a known instance version we can't gate — treat as usable. + return { status: 'available', deprecated: !!max, requiredVersion: min } + } + if (min && SemVer.valid(min) && SemVer.lt(version, min)) { + return { status: 'requires-update', deprecated: false, requiredVersion: min } + } + if (max && SemVer.valid(max) && SemVer.gt(version, max)) { + return { status: 'deprecated', deprecated: true, requiredVersion: null } + } + return { status: 'available', deprecated: !!max, requiredVersion: null } } }, actions: { @@ -548,6 +633,52 @@ export const useProductAssistantStore = defineStore('product-assistant', { } }) }, + // --- Expert tool permissions (HITL, #421) --- + setToolCatalog (catalog, hash) { + this.toolCatalog = Array.isArray(catalog) ? catalog : [] + if (hash !== undefined) { + this.toolCatalogHash = hash || null + } + }, + setToolClassDefault (cls, policy) { + if (!TOOL_CLASSES.includes(cls) || !isToolPolicy(policy)) return + this.toolDefaults = { ...this.toolDefaults, [cls]: policy } + }, + setToolPreference (key, policy) { + if (!isToolPolicy(policy)) return + this.toolPreferences = { ...this.toolPreferences, [key]: policy } + }, + clearToolPreference (key) { + if (!(key in this.toolPreferences)) return + const next = { ...this.toolPreferences } + delete next[key] + this.toolPreferences = next + }, + // Pending approvals (module-level map; see note at top of file). + registerPendingApproval (id, resolve, meta = {}) { + pendingToolApprovals.set(id, { resolve, meta }) + }, + getPendingApproval (id) { + return pendingToolApprovals.get(id) || null + }, + resolvePendingApproval (id, approved) { + const entry = pendingToolApprovals.get(id) + if (!entry) return false + pendingToolApprovals.delete(id) + entry.resolve(!!approved) + return true + }, + hasPendingApprovals () { + return pendingToolApprovals.size > 0 + }, + // Resolve every open approval as denied — used when the user stops the chat so + // the agent's approval wait unblocks instead of hanging on an abandoned prompt. + rejectAllPendingApprovals () { + for (const entry of pendingToolApprovals.values()) { + entry.resolve(false) + } + pendingToolApprovals.clear() + }, sendMessage (payload) { const orchestrator = getAppOrchestrator() const contextStore = useContextStore() @@ -561,5 +692,11 @@ export const useProductAssistantStore = defineStore('product-assistant', { targetOrigin: (contextStore.instance || contextStore.device)?.url }) } + }, + // Only the user's HITL tool-permission choices persist across sessions; the + // catalog/hash and all editor/session state are re-derived each session. + persist: { + pick: ['toolDefaults', 'toolPreferences'], + storage: localStorage } }) diff --git a/frontend/src/stores/product-expert.js b/frontend/src/stores/product-expert.js index da26708210..8ad039b6c1 100644 --- a/frontend/src/stores/product-expert.js +++ b/frontend/src/stores/product-expert.js @@ -292,7 +292,8 @@ export const useProductExpertStore = defineStore('product-expert', { query, context: { ...useContextStore().expert, - agent: this.agentMode + agent: this.agentMode, + ...(contextOverrides || {}) } }, correlationData: transactionId, @@ -322,11 +323,36 @@ export const useProductExpertStore = defineStore('product-expert', { onDisconnect: this._onMqttDisconnect }) }, + async fetchToolCatalog () { + // Fetch the tool catalog for the permissions UI (#421) over HTTP + // (GET /api/v1/expert/mcp/tools), mirroring the insights `getCapabilities` + // pattern. This deliberately does NOT use MQTT — the catalog is needed before + // any chat, and we must not open (or keep open) the broker connection on mount. + // The agent replies with a curated, friendly catalog (raw tool identifiers + // never reach the browser) plus a `hash` we store to detect later drift. + const assistantStore = useProductAssistantStore() + if (!assistantStore.isImmersiveInstance && !assistantStore.isImmersiveDevice) return + + const teamId = useContextStore().expert?.teamId + if (!teamId) return + + try { + const { catalog, hash } = await expertApi.getToolCatalog({ teamId }) + assistantStore.setToolCatalog(catalog || [], hash || null) + } catch (e) { + // Non-fatal: the agent still gates safely with defaults if the catalog + // is unavailable; the settings UI simply shows no tools yet. + } + }, async handleInFlightRequest ({ topic, message, transactionId, sessionId, chatTransactionId } = {}) { - const inFlightRequest = this._inFlightRequests.values().next().value + // Match the originating chat request explicitly (not just the first entry) so a + // concurrent in-flight request — e.g. an open tool approval — can't shadow it and + // cause us to drop a valid in-flight request. + const inFlightRequest = Array.from(this._inFlightRequests.values()) + .find(r => r.transactionId === chatTransactionId) // dismiss inFlight requests that don't match the existing sessionId or the inFlight message transactionId - if (sessionId !== this.sessionId || inFlightRequest?.transactionId !== chatTransactionId) return + if (sessionId !== this.sessionId || !inFlightRequest) return const servicesOrchestrator = getAppOrchestrator() const assistantStore = useProductAssistantStore() @@ -389,6 +415,26 @@ export const useProductExpertStore = defineStore('product-expert', { this._onMqttError(e) } break + case parsedTopic.inflightType === 'expert:tool-approval': + // Human-in-the-loop approval request (#421). Render the approval card and + // wait — with no timeout — for the user's decision, then reply to the agent. + try { + const approved = await this.requestToolApproval(payload) + await mqttService.publishMessage(this.mqttConnectionKey, { + qos: 2, + topic: responseTopic, + payload: JSON.stringify({ approved }), + correlationData: transactionId, + userProperties: { + sessionId, + transactionId: chatTransactionId, + origin: window.origin || window.location.origin + } + }) + } catch (e) { + this._onMqttError(e) + } + break default: // do nothing } @@ -397,11 +443,76 @@ export const useProductExpertStore = defineStore('product-expert', { // ignore aborted messages through mqtt if (Object.prototype.hasOwnProperty.call(response, 'aborted') && response.aborted === true) return + // Tool-catalog freshness (#421): the agent stamps a catalog hash on every + // response. If it differs from what we hold, the catalog drifted (e.g. a + // rolling deploy landed a new tool version) — refetch the full list in the + // background. Only the small hash rides on each interaction. + const incomingHash = response?.toolCatalogHash + if (incomingHash && incomingHash !== useProductAssistantStore().toolCatalogHash) { + this.fetchToolCatalog() + } + if (response.answer && Array.isArray(response.answer)) { this.addAiMessage(response) this._clearInFlightUpdates() } }, + // Render a tool-approval card and return a Promise that resolves to the user's + // decision (true/false). The Promise stays open with no timeout (#421); it is + // resolved by resolveToolApproval (a card button) or cancelPendingToolApprovals + // (the chat stop). The agent holds its tool call paused on the MQTT round-trip. + requestToolApproval (payload = {}) { + const permStore = useProductAssistantStore() + const id = uuidv4() + this.addAiMessage({ + kind: 'tool-approval', + answer: [{ + kind: 'tool-approval', + id, + toolKey: payload.tool, + name: payload.name, + summary: payload.summary, + toolClass: payload.toolClass, + params: payload.params, + status: 'pending' + }] + }) + return new Promise((resolve) => { + permStore.registerPendingApproval(id, resolve, { toolKey: payload.tool }) + }) + }, + resolveToolApproval ({ id, approved, always } = {}) { + const permStore = useProductAssistantStore() + const entry = permStore.getPendingApproval(id) + if (!entry) return + // "Always allow" persists an allow preference for this tool. + if (always && approved && entry.meta?.toolKey) { + permStore.setToolPreference(entry.meta.toolKey, 'allow') + } + // Reflect the outcome on the card so its buttons disable. + this._setToolApprovalStatus(id, approved ? 'approved' : 'denied') + permStore.resolvePendingApproval(id, approved) + }, + // Deny every open approval (used when the user stops the chat) so the agent's + // approval wait unblocks instead of hanging on an abandoned prompt. + cancelPendingToolApprovals () { + const permStore = useProductAssistantStore() + if (!permStore.hasPendingApprovals()) return + for (const m of this._agentStore.messages) { + if (!Array.isArray(m.answer)) continue + for (const a of m.answer) { + if (a.kind === 'tool-approval' && a.status === 'pending') a.status = 'denied' + } + } + permStore.rejectAllPendingApprovals() + }, + _setToolApprovalStatus (id, status) { + for (const m of this._agentStore.messages) { + if (!Array.isArray(m.answer)) continue + const ans = m.answer.find(a => a.kind === 'tool-approval' && a.id === id) + if (ans) { ans.status = status; return } + } + }, async startOver () { const agentStore = this._agentStore agentStore.sessionId = uuidv4() @@ -1054,6 +1165,8 @@ export const useProductExpertStore = defineStore('product-expert', { this.addPredefinedAiMessage(payload.message, { isError: true, code: payload.code }) }, stopInflightChat () { + // Deny any open approval prompts first so the agent's paused tool call unblocks. + this.cancelPendingToolApprovals() if (this.shouldUseMqtt) { const inFlightRequest = this._inFlightRequests.values().next().value const servicesOrchestrator = getAppOrchestrator() From bdb36fbada95713e12d0ca7ca65000bc482fdc88 Mon Sep 17 00:00:00 2001 From: andypalmi Date: Tue, 30 Jun 2026 15:39:54 +0200 Subject: [PATCH 12/40] refactor(expert): align tool-permissions UI with FlowFuse patterns Use FormHeading for the section titles and ff-data-table for both the action-type defaults and the flow-building tool list, replacing the bespoke section/group styling and the non-standard uppercase scope headers. Bordered table rows pair each tool with its permission control across the row rather than leaving them to float across whitespace; tool scope moves into a Type column. The approval card no longer sends or renders a tool summary; the tool name, scope and call parameters describe the action. --- .../components/ToolPermissionsSettings.vue | 185 ++++++++---------- .../messages/components/AnswerWrapper.vue | 1 - .../components/resources/ToolApprovalCard.vue | 9 - frontend/src/stores/product-expert.js | 1 - 4 files changed, 87 insertions(+), 109 deletions(-) diff --git a/frontend/src/components/expert/components/ToolPermissionsSettings.vue b/frontend/src/components/expert/components/ToolPermissionsSettings.vue index fe7e8627ba..103261cd17 100644 --- a/frontend/src/components/expert/components/ToolPermissionsSettings.vue +++ b/frontend/src/components/expert/components/ToolPermissionsSettings.vue @@ -1,23 +1,29 @@