Skip to content
3 changes: 2 additions & 1 deletion frontend/src/store/modules/account/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import product from '../../../services/product.js'
import { useContextStore } from '@/stores/context.js'
import { useProductAssistantStore } from '@/stores/product-assistant.js'
import { useProductExpertInsightsAgentStore } from '@/stores/product-expert-insights-agent.js'
import { useProductExpertOperatorAgentStore } from '@/stores/product-expert-operator-agent.js'
import { useUxDialogStore } from '@/stores/ux-dialog.js'
import { useUxDrawersStore } from '@/stores/ux-drawers.js'
import { useUxNavigationStore } from '@/stores/ux-navigation.js'
Expand Down Expand Up @@ -531,7 +532,7 @@ const actions = {
// Task 7: useProductBrokersStore().$reset()
useProductAssistantStore().$reset()
useProductExpertInsightsAgentStore().$reset()
// Task 10: useProductExpertOperatorAgentStore().$reset()
useProductExpertOperatorAgentStore().$reset()
// Task 11: useProductExpertStore().$reset()
}
})
Expand Down
1 change: 1 addition & 0 deletions frontend/src/stores/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Barrel export — add store exports here as each task is merged
export { useContextStore } from './context.js'
export { useProductAssistantStore } from './product-assistant.js'
export { useProductExpertOperatorAgentStore } from './product-expert-operator-agent.js'
export { useUxDrawersStore } from './ux-drawers.js'
export { useUxNavigationStore } from './ux-navigation.js'
export { useUxStore } from './ux.js'
Expand Down
60 changes: 60 additions & 0 deletions frontend/src/stores/product-expert-operator-agent.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { defineStore } from 'pinia'
import { markRaw } from 'vue'

import expertApi from '../api/expert.js'
import useTimerHelper from '../composables/TimerHelper.js'

import { useAccountBridge } from './_account_bridge.js'

export const useProductExpertOperatorAgentStore = defineStore('product-expert-operator-agent', {
state: () => ({
sessionId: null,
messages: [],
abortController: null,
sessionStartTime: null,
sessionWarningShown: false,
sessionExpiredShown: false,
sessionCheckTimer: null,
capabilityServers: [],
selectedCapabilities: []
}),
getters: {
capabilities: (state) => state.capabilityServers.map(c => ({
...c,
toolCount: c.resources.length + c.tools.length + c.prompts.length
}))
},
actions: {
reset () {
if (this.sessionCheckTimer) clearInterval(this.sessionCheckTimer)
Object.assign(this, {
sessionId: null,
messages: [],
abortController: null,
sessionStartTime: null,
sessionWarningShown: false,
sessionExpiredShown: false,
sessionCheckTimer: null,
capabilityServers: [],
selectedCapabilities: []
})
},
setSelectedCapabilities (caps) { this.selectedCapabilities = caps },
setSessionCheckTimer (timer) { this.sessionCheckTimer = markRaw(timer) },
async getCapabilities () {
// TODO: this need to be removed when we have https://github.com/FlowFuse/flowfuse/issues/6520 part of
// https://github.com/FlowFuse/flowfuse/issues/6519 as it's a hacky workaround to the expert drawer opening up
// before we have a team loaded
const { waitWhile } = useTimerHelper()
await waitWhile(() => !useAccountBridge().team, { cutoffTries: 60 })

const { team } = useAccountBridge()
const data = await expertApi.getCapabilities({ context: { teamId: team.id } })
this.capabilityServers = data.servers || []
}
},
persist: {
pick: ['sessionId'],
storage: localStorage
}
})
97 changes: 97 additions & 0 deletions test/unit/frontend/stores/product-expert-operator-agent.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'

import { useProductExpertOperatorAgentStore } from '@/stores/product-expert-operator-agent.js'

vi.mock('@/stores/_account_bridge.js', () => ({
useAccountBridge: vi.fn(() => ({ team: { id: 'team-1' } }))
}))

vi.mock('@/api/expert.js', () => ({
default: {
getCapabilities: vi.fn()
}
}))

// imported after mocks so vi.mock hoisting resolves correctly
const { default: expertApi } = await import('@/api/expert.js')

describe('product-expert-operator-agent store', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})

it('initializes with empty capabilities and messages', () => {
const store = useProductExpertOperatorAgentStore()
expect(store.capabilities).toEqual([])
expect(store.messages).toEqual([])
expect(store.sessionId).toBeNull()
expect(store.selectedCapabilities).toEqual([])
expect(store.abortController).toBeNull()
})

it('getCapabilities fetches and stores server list', async () => {
const store = useProductExpertOperatorAgentStore()
const server = { id: 'srv-1', resources: [], tools: [], prompts: [] }
vi.spyOn(expertApi, 'getCapabilities').mockResolvedValue({ servers: [server] })
await store.getCapabilities()
// capabilities getter maps capabilityServers and adds toolCount
expect(store.capabilities).toEqual([{ ...server, toolCount: 0 }])
expect(store.capabilityServers).toEqual([server])
})

it('getCapabilities handles missing servers key', async () => {
const store = useProductExpertOperatorAgentStore()
vi.spyOn(expertApi, 'getCapabilities').mockResolvedValue({})
await store.getCapabilities()
expect(store.capabilityServers).toEqual([])
})

it('capabilities getter computes toolCount correctly', () => {
const store = useProductExpertOperatorAgentStore()
store.capabilityServers = [{
id: 'srv-1',
resources: ['r1', 'r2'],
tools: ['t1'],
prompts: ['p1', 'p2', 'p3']
}]
expect(store.capabilities[0].toolCount).toBe(6)
})

it('setSelectedCapabilities updates selectedCapabilities', () => {
const store = useProductExpertOperatorAgentStore()
store.setSelectedCapabilities(['cap-a', 'cap-b'])
expect(store.selectedCapabilities).toEqual(['cap-a', 'cap-b'])
})

it('setSessionCheckTimer stores the timer reference', () => {
const store = useProductExpertOperatorAgentStore()
const fakeTimer = setInterval(() => {}, 9999)
store.setSessionCheckTimer(fakeTimer)
expect(store.sessionCheckTimer).toBe(fakeTimer)
clearInterval(fakeTimer)
})

it('reset clears timer and resets all state', () => {
const store = useProductExpertOperatorAgentStore()
const fakeTimer = setInterval(() => {}, 9999)
const clearSpy = vi.spyOn(globalThis, 'clearInterval')
store.setSessionCheckTimer(fakeTimer)
store.sessionId = 'sess-abc'
store.abortController = new AbortController()
store.capabilityServers = [{ id: 'srv-1' }]
store.reset()
expect(clearSpy).toHaveBeenCalledWith(fakeTimer)
expect(store.sessionId).toBeNull()
expect(store.abortController).toBeNull()
expect(store.capabilityServers).toEqual([])
expect(store.messages).toEqual([])
clearInterval(fakeTimer)
})

it('reset with no timer does not throw', () => {
const store = useProductExpertOperatorAgentStore()
expect(() => store.reset()).not.toThrow()
})
})
Loading