diff --git a/AGENTS.md b/AGENTS.md index b3d9a7e53..e7f8a3e9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,12 @@ Narrate what you are doing as you go. Do not work silently and present a result * Keep validation errors user-facing and predictable. * For PATCH requests, nullable fields are acceptable when omitted values should remain unchanged. +## Buddy app guide + +* `onboarding/service/AppGuide.kt` is the buddy's map of the frontend: every page, where it is, its tabs and their deep links, and who may open it. It is hand-written and mirrors `sprintstart-frontend/src/auth/accessPolicy.ts` and the UI's own labels. +* When a frontend page, tab, tab URL parameter or quoted label changes, update `AppGuide.kt` (and the route list in `AppGuideTest`) in the same piece of work, and the frontend's `src/features/buddy/appGuideRoutes.ts` with it. +* A how-to's `link` may only use a URL parameter the frontend actually reads. + ## Do not * Do not add new production dependencies without explaining why and confirming it with me. diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt index 12b44fdf9..6b048c098 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyController.kt @@ -281,6 +281,7 @@ class BuddyController( request.content, request.capabilitiesEnabled, request.filters, + request.currentPage, ) } else { buddyTeamService.sendMessageForMe( @@ -288,6 +289,7 @@ class BuddyController( teamProjectId, request.content, request.capabilitiesEnabled, + request.currentPage, ) } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt index 171bd3887..770bec17c 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/model/request/buddy/SendBuddyMessageRequest.kt @@ -30,4 +30,11 @@ data class SendBuddyMessageRequest( */ val teamProjectId: UUID? = null, val filters: BuddySessionFilters? = null, + /** + * The app path the sender was on (`/team-management`, `/team/…`), so "where is this on the page + * I'm on?" can be answered. Only ever matched against the app guide, never echoed; absent from + * older clients. Not validated: an odd value must cost the guide a line, never the hire their + * message. + */ + val currentPage: String? = null, ) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt new file mode 100644 index 000000000..caf40af28 --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuide.kt @@ -0,0 +1,393 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.user.external.enums.Role + +/** + * One thing a person can do on a page, and who may do it. + * + * @property task What it achieves, in the words somebody would ask for it ("create a project"). + * @property steps Where to click, in the UI's own labels. + * @property doers Who may do it, when that is narrower than who may open the page. A page HR can + * open can still hold a button only an admin may press, and a guide that ignores the difference + * sends HR to a button that refuses them. + * @property link A deep link that lands on the right tab already (`/team-management?tab=roles`), + * when the page keeps its tab in the URL. Only ever a parameter the frontend actually reads: a + * link that lands on the wrong tab is worse than a plain one. + */ +data class AppGuideHowTo( + val task: String, + val steps: String, + val doers: Set? = null, + val link: String? = null, +) + +/** + * One page of the Sprintstart app, described for the buddy. + * + * @property path The route, which is also how the buddy links to the page. Always root-relative, + * so the chat renders it as an in-app link rather than a new tab. A `{placeholder}` marks an id the + * buddy has to fill in from another tool before linking. + * @property matches Prefixes of detail routes that belong to this page (`/team/` for one member), + * so the page somebody is looking at can be named even when its URL carries an id. + * @property roles Who may open it — mirrors the frontend's `accessPolicy.ts` `routePermissions`. + * @property managerScoped Whether a PM additionally has to manage the selected project — mirrors + * `MANAGER_ASSIGNMENT_ROUTES` there. HR and admins are gated by role alone. + */ +data class AppGuidePage( + val name: String, + val path: String, + val whereToFind: String, + val purpose: String, + val howTos: List = emptyList(), + val roles: Set = EVERYONE, + val managerScoped: Boolean = false, + val matches: List = emptyList(), +) { + /** Whether [path] needs an id filled in before it can be linked (`/team/{member_id}`). */ + val needsId: Boolean get() = '{' in path +} + +private val EVERYONE = setOf(Role.USER, Role.PM, Role.HR, Role.ADMIN) +private val MANAGERS = setOf(Role.PM, Role.HR, Role.ADMIN) +private val ADMIN_HR = setOf(Role.HR, Role.ADMIN) +private val PM_ADMIN = setOf(Role.PM, Role.ADMIN) +private val ADMIN_ONLY = setOf(Role.ADMIN) + +// The PM area is one page with a tab bar (`PmWorkspace` in the frontend); six of the routes below +// are its tabs. Named once so every entry says where the tab bar is the same way. +private const val PM_TAB = "PM Dashboard (sidebar) → the" + +/** + * The map of the app the buddy reads from, so "where do I…" is answered from the app rather than + * from the project's documentation — which describes the *project*, and has never heard of a page + * called PM Dashboard. + * + * ### Keeping it true + * + * This is a hand-written copy of what the frontend shows, and it goes stale the moment a page is + * renamed there. What keeps that visible rather than silent: + * + * - The labels are the UI's own (sidebar entries, tab names, button text — quoted, so a rename is + * a grep away). + * - `AppGuideTest` pins the route list here, and the frontend's `appGuideRoutes.test.ts` fails + * when `accessPolicy.ts` gains a route that neither this guide describes nor is excluded on + * purpose — so a new page breaks CI in the repo it was added in. + * - Both repos' `AGENTS.md` say to update this file with any page, tab or label change. + * + * The roles mirror `sprintstart-frontend/src/auth/accessPolicy.ts`; when that file changes, this + * one has to follow. + * + * Deliberately no page for the login screen, the skill wizard or the 404: nobody asks the buddy + * how to reach them, and every entry costs prompt space on every question that does. + */ +object AppGuide { + /** Not a page: the project selector every project-scoped page follows. */ + const val PROJECT_SWITCHER = + "Most pages show the project selected in the account menu at the bottom of the sidebar " + + "(click your avatar, then \"Switch project\"). A page that looks empty or shows the " + + "wrong team is usually showing a different project." + + /** Not a page either: the buddy itself, which lives on every page. */ + const val BUDDY_EVERYWHERE = + "The buddy is the floating window in the corner of every page (drag it to another " + + "corner), and also a full page at /buddy next to the Chat." + + val pages: List = listOf( + AppGuidePage( + name = "Dashboard", + path = "/", + whereToFind = "sidebar, \"Dashboard\" (the start page)", + purpose = "Personal overview: greeting, next step, onboarding progress, skills, " + + "recent chats and project widgets.", + howTos = listOf( + AppGuideHowTo( + "change which widgets show", + "\"Edit dashboard\" → add or remove widgets (\"Your widgets\"), then \"Done\"; " + + "resetting restores the default layout", + ), + ), + ), + AppGuidePage( + name = "Board", + path = "/board", + whereToFind = "sidebar, \"Board\"", + purpose = "The hire's own board: the current task, path steps, notes, checklists and " + + "links (many placed by the buddy), arranged in stages and areas.", + ), + AppGuidePage( + name = "Chat", + path = "/chat", + whereToFind = "sidebar, \"Chat\"", + purpose = "Ask the AI questions about the project's documentation and code; answers " + + "cite their sources. Separate from the buddy, which also knows the person's " + + "own onboarding.", + matches = listOf("/chat/"), + ), + AppGuidePage( + name = "Buddy", + path = "/buddy", + whereToFind = "the Buddy tab next to Chat, or the floating buddy on any page", + purpose = "The onboarding buddy as a full page, with the list of their earlier conversations.", + howTos = listOf( + AppGuideHowTo( + "start a fresh conversation with the buddy", + "\"Start a new conversation\" (also on the floating buddy); the buddy keeps what " + + "it has learned about them", + ), + ), + ), + AppGuidePage( + name = "Knowledge Base", + path = "/knowledge-base", + whereToFind = "sidebar, \"Knowledge Base\"", + purpose = "Browse the project's ingested documentation, code runbooks and artifacts.", + ), + AppGuidePage( + name = "OnBoarding", + path = "/onboarding", + whereToFind = "sidebar, \"OnBoarding\" (shown until their onboarding is finished)", + purpose = "The person's onboarding path: phases, steps and knowledge-check questions, " + + "in the order they unlock.", + howTos = listOf( + AppGuideHowTo( + "work through a step", + "open the step in the current phase → \"Start step\", and \"Mark as complete\" " + + "when finished", + ), + AppGuideHowTo( + "get help with a step or a question", + "\"Ask your buddy about this\" on it brings it into the buddy chat", + ), + ), + matches = listOf("/onboarding/"), + ), + AppGuidePage( + name = "Settings", + path = "/settings", + whereToFind = "the gear / account menu at the bottom of the sidebar, \"Settings\"", + purpose = "Tabs \"User Profile\" (name, profile icon, GitHub username), \"Appearance\" " + + "(theme) and, for project managers, HR and admins, \"Access Tokens\".", + howTos = listOf( + AppGuideHowTo( + "set a GitHub username so pull requests are found", + "Settings → \"User Profile\"", + ), + ), + ), + AppGuidePage( + name = "PM Dashboard", + path = "/pm-dashboard", + whereToFind = "sidebar, \"PM Dashboard\" — one page with the tabs \"Overview\", \"Team\" " + + "(Members, Roles), \"Onboarding\", \"Questions\", \"Knowledge gaps\" and \"Escalations\"", + purpose = "The selected project at a glance (the \"Overview\" tab): who needs attention, " + + "ingestion status and the project analysis. The other tabs are listed separately.", + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Team", + path = "/team-management", + whereToFind = "$PM_TAB \"Team\" tab, with the views \"Members\" and \"Roles\"", + purpose = "\"Members\": everybody on the project and their onboarding progress. \"Roles\": " + + "the project's roles and the skills each needs.", + howTos = listOf( + AppGuideHowTo( + "create a project role", + "Team → \"Roles\" → \"Create role\" (name and what it is responsible for)", + link = "/team-management?tab=roles", + ), + AppGuideHowTo( + "give a role its skills", + "Team → \"Roles\" → pick the role → \"Skills\": add one, or \"Suggest skills\"", + link = "/team-management?tab=roles", + ), + AppGuideHowTo( + "assign members to a role", + "Team → \"Roles\" → pick the role → \"Members\"; \"Without a role\" lists who has " + + "none yet", + link = "/team-management?tab=roles", + ), + AppGuideHowTo( + "look at one member", + "Team → \"Members\" → click the person for a side panel, \"Full profile\" for " + + "their page", + link = "/team-management", + ), + ), + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Team member", + path = "/team/{member_id}", + whereToFind = "$PM_TAB \"Team\" tab → \"Members\" → a person → \"Full profile\"", + purpose = "One member's full profile: their roles, current step, onboarding path, " + + "feedback and skip requests, and skill and knowledge gaps.", + howTos = listOf( + AppGuideHowTo( + "choose or change a member's role", + "under their name: the \"Choose role\" / \"Add role\" picker adds one, the × on a " + + "role removes it", + ), + AppGuideHowTo( + "rebuild a member's onboarding path with AI", + "\"Rebuild path\" in the member's header", + doers = PM_ADMIN, + ), + ), + roles = MANAGERS, + managerScoped = true, + matches = listOf("/team/"), + ), + AppGuidePage( + name = "Onboarding (PM)", + path = "/insights/onboarding", + whereToFind = "$PM_TAB \"Onboarding\" tab", + purpose = "Contribution metrics per hire: pull requests, review waits, stalls.", + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Questions", + path = "/insights/faq", + whereToFind = "$PM_TAB \"Questions\" tab", + purpose = "The questions the team asks most, grouped.", + roles = MANAGERS, + managerScoped = true, + matches = listOf("/insights/faq/"), + ), + AppGuidePage( + name = "Knowledge gaps", + path = "/insights/knowledge-gaps", + whereToFind = "$PM_TAB \"Knowledge gaps\" tab", + purpose = "Topics the documentation does not cover well enough, from what people asked.", + roles = MANAGERS, + managerScoped = true, + matches = listOf("/insights/knowledge-gaps/"), + ), + AppGuidePage( + name = "Escalations", + path = "/insights/knowledge-requests", + whereToFind = "$PM_TAB \"Escalations\" tab, with the views \"Open\" and \"Durable answers\"", + purpose = "Questions hires flagged because the buddy could not answer them, and the " + + "answers already written for them.", + howTos = listOf( + AppGuideHowTo( + "answer a hire's escalated question", + "Escalations → \"Open\" → open it and answer; the answer becomes team knowledge " + + "the buddy uses from then on", + doers = PM_ADMIN, + link = "/insights/knowledge-requests", + ), + AppGuideHowTo( + "see answers already written", + "Escalations → \"Durable answers\"", + link = "/insights/knowledge-requests?view=answered", + ), + ), + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Data Ingestion", + path = "/data-ingestion", + whereToFind = "sidebar, \"Data Ingestion\" (the project manager section)", + purpose = "The material the AI and the buddy answer from, for the selected project, " + + "and whether it is ingested yet.", + howTos = listOf( + AppGuideHowTo( + "connect GitHub, Jira, Confluence or upload documents", + "\"Add sources\" → pick the kind, fill it in → \"Connect now\"", + ), + ), + roles = MANAGERS, + managerScoped = true, + ), + AppGuidePage( + name = "Blueprints", + path = "/blueprints", + whereToFind = "sidebar, \"Blueprints\" (the project manager section)", + purpose = "Reusable onboarding path templates (\"Project blueprints\", and for admins " + + "\"Global blueprints\"). Editing one never changes a hire's live path.", + howTos = listOf( + AppGuideHowTo( + "create an onboarding blueprint", + "\"New blueprint path\", then add phases and steps in the \"Graph\" or \"Outline\" " + + "view", + ), + AppGuideHowTo( + "see earlier versions of a blueprint", + "open it → \"Version history\"", + ), + ), + roles = MANAGERS, + managerScoped = true, + matches = listOf("/blueprints/"), + ), + AppGuidePage( + name = "Hire Setup", + path = "/hire-setup", + whereToFind = "sidebar, \"Hire Setup\" (the project manager section), tabs \"Arrival\" " + + "and \"Starter work\"", + purpose = "What a new hire needs before they start (accounts, access, setup) and the " + + "first work waiting for them.", + howTos = listOf( + AppGuideHowTo( + "write the arrival checklist (accounts, access, setup)", + "Hire Setup → \"Arrival\" tab", + doers = PM_ADMIN, + link = "/hire-setup?tab=arrival", + ), + AppGuideHowTo( + "review the first tasks offered to hires", + "Hire Setup → \"Starter work\" tab", + doers = PM_ADMIN, + link = "/hire-setup?tab=starter", + ), + ), + roles = MANAGERS, + ), + AppGuidePage( + name = "Access Management", + path = "/admin", + whereToFind = "sidebar, \"Access Management\", tabs \"Users\", \"Projects\", \"Skills\" " + + "(admins only) and \"Tokens\"", + purpose = "Users, projects, the organisation-wide skill pool and access tokens.", + howTos = listOf( + AppGuideHowTo( + "create a project", + "\"Projects\" tab → \"New Project\": Details (name, description, industry, " + + "project manager) → Members → Sources → Review", + doers = ADMIN_ONLY, + link = "/admin?tab=projects", + ), + AppGuideHowTo( + "make somebody a project's manager, or add people to a project", + "\"Projects\" tab → open the project", + doers = ADMIN_ONLY, + link = "/admin?tab=projects", + ), + AppGuideHowTo( + "change somebody's permission group (member, PM, HR, admin)", + "\"Users\" tab → open the user", + doers = ADMIN_ONLY, + link = "/admin?tab=users", + ), + AppGuideHowTo( + "add, edit or retire a skill in the organisation-wide pool", + "\"Skills\" tab → \"New skill\", or open a skill to edit it or \"Retire skill\"; " + + "taking a skill off one role is done in Team → \"Roles\" instead", + doers = ADMIN_ONLY, + link = "/admin?tab=skills", + ), + ), + roles = ADMIN_HR, + ), + ) + + /** The page [pathname] belongs to, or null for a route this guide does not describe. */ + fun pageAt(pathname: String): AppGuidePage? = + // Detail routes first: `/team/…` must not be read as Team's own path. + pages.firstOrNull { page -> page.matches.any { pathname.startsWith(it) } } + ?: pages.firstOrNull { it.path == pathname } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt new file mode 100644 index 000000000..48ed505ad --- /dev/null +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideTools.kt @@ -0,0 +1,196 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.onboarding.external.model.BuddyToolSpecDto +import com.sprintstart.sprintstartbackend.user.external.UserApi +import com.sprintstart.sprintstartbackend.user.external.enums.Role +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.springframework.stereotype.Component +import java.util.UUID + +/** + * The buddy's knowledge of the app itself: which pages the caller can open, what is on them, how to + * do things there, and who to ask for what they cannot do. + * + * Before this the buddy had one place to look for "how do I create a project?" — `search_docs` — + * and that corpus is the *project's* material, which has never heard of Sprintstart's pages. It + * either said nothing useful or described a screen that does not exist. + * + * Mounted for everybody, in both the hire's buddy and team mode: everybody uses the app, and what + * changes with the reader is what the guide says, not whether it exists. + * + * ### Read for the caller, never for somebody else + * + * The guide is filtered by the caller's own permission group and, for a PM, by the projects they + * actually manage — the same two facts the frontend's route guard decides on. A page the caller + * cannot open is never offered as a place to go; what happens there is listed as somebody else's + * job, with who that somebody is. Telling a PM "go to Access Management → New Project" sends them to + * a page their sidebar does not have. + * + * This is guidance, not authorisation: every page still enforces its own access, so a guide that + * drifted from the frontend would mislead, never let anybody in. + */ +@Component +class BuddyAppGuideTools( + private val userApi: UserApi, +) { + /** + * The guide for [userId], naming the page they are on when [currentPage] is known. + * + * @param currentPage The path the caller's browser was on when they spoke (query and fragment + * are ignored). Whatever the client sent, so it is only ever matched against the guide, never + * echoed back verbatim. + */ + fun guideFor(userId: UUID, currentPage: String?): String { + val role = userApi.getPermissionGroup(userId) ?: Role.USER + val managed = if (role == Role.PM) managedProjectNames(userId) else emptyList() + val reader = Reader(role, managesAProject = role != Role.PM || managed.isNotEmpty()) + + val openable = AppGuide.pages.filter { reader.opens(it) } + val elsewhere = AppGuide.pages.flatMap { page -> + page.howTos + .filter { !reader.opens(page) || (it.doers != null && role !in it.doers) } + .map { page to it } + } + + return buildString { + appendLine(whoIsAsking(role, managed)) + here(currentPage)?.let { appendLine(it) } + appendLine() + appendLine( + "Pages they can open (link each as [Name](path); where a how-to has its own link, " + + "use that one — it opens the right tab):", + ) + openable.forEach { page -> appendPage(page, role) } + if (elsewhere.isNotEmpty()) { + appendLine() + appendLine("Only somebody else can do these — say who to ask, never send them to the page:") + elsewhere.forEach { (page, howTo) -> + val who = describeDoers(howTo.doers ?: page.roles) + appendLine("- ${howTo.task}: $who, on ${page.name} (${howTo.steps})") + } + } + if (reader.blockedFromManagerPages()) { + appendLine() + appendLine( + "They hold the PM role but manage no project, so the $MANAGER_PAGES stay hidden. An admin " + + "makes somebody a project's manager in Access Management → Projects.", + ) + } + appendLine() + appendLine("Anywhere in the app:") + appendLine("- ${AppGuide.PROJECT_SWITCHER}") + append("- ${AppGuide.BUDDY_EVERYWHERE}") + } + } + + private fun StringBuilder.appendPage(page: AppGuidePage, role: Role) { + val link = if (page.needsId) "${page.path} — fill the id in from another tool" else page.path + appendLine("- ${page.name} ($link) — ${page.whereToFind}") + appendLine(" ${page.purpose}") + page.howTos + .filter { it.doers == null || role in it.doers } + .forEach { howTo -> + val link = howTo.link?.let { " (link: $it)" }.orEmpty() + appendLine(" · to ${howTo.task}: ${howTo.steps}$link") + } + } + + /** The names of the caller's projects that they manage — the PM-only gate on manager pages. */ + private fun managedProjectNames(userId: UUID): List { + val authId = userApi.getAuthIdByUserId(userId).orElse(null) ?: return emptyList() + return userApi + .getUsersByIds(listOf(userId)) + .firstOrNull() + ?.projects + .orEmpty() + .filter { userApi.canManageProject(authId, it.projectId) } + .map { it.name } + } + + private fun whoIsAsking(role: Role, managed: List): String { + val base = "Who is asking: ${ROLE_NAMES.getValue(role)}." + return when { + role != Role.PM -> base + managed.isEmpty() -> "$base They manage no project yet." + // The frontend gates the manager pages on the *selected* project, not on managing any + // one; without this a PM looking at a project they only belong to is sent to a sidebar + // entry that is not there. + else -> "$base They manage: ${managed.joinToString(", ")}. The $MANAGER_PAGES show only " + + "while one of these is the selected project." + } + } + + /** The page the caller is looking at, or null when nothing usable was sent. */ + private fun here(currentPage: String?): String? { + val pathname = currentPage + ?.substringBefore('?') + ?.substringBefore('#') + ?.takeIf { it.startsWith("/") && it.length <= MAX_PATH_LENGTH } + ?: return null + val page = AppGuide.pageAt(pathname) + ?: return "They are on a page this guide does not describe." + return "They are looking at: ${page.name} (${page.path}). \"This page\" or \"here\" means this one." + } + + private fun describeDoers(doers: Set): String { + val names = DOER_ORDER.filter { it in doers }.map { DOER_NAMES.getValue(it) } + if (names.size <= 1) return names.joinToString() + return names.dropLast(1).joinToString(", ") + " or " + names.last() + } + + /** The two facts the frontend's route guard decides a page on. */ + private class Reader( + val role: Role, + val managesAProject: Boolean, + ) { + fun opens(page: AppGuidePage): Boolean = + role in page.roles && (!page.managerScoped || role != Role.PM || managesAProject) + + fun blockedFromManagerPages(): Boolean = role == Role.PM && !managesAProject + } + + companion object { + const val GET_APP_GUIDE = "get_app_guide" + + // A path is a few segments and an id; anything longer is not a route of this app. + private const val MAX_PATH_LENGTH = 200 + + // The pages `AppGuidePage.managerScoped` marks, as somebody would name them. + private const val MANAGER_PAGES = "manager pages (PM Dashboard and its tabs, Data Ingestion, Blueprints)" + + private val ROLE_NAMES = mapOf( + Role.USER to "a team member", + Role.PM to "a project manager (PM)", + Role.HR to "HR", + Role.ADMIN to "an admin", + ) + + // Who does it, named the way somebody would go and find them. A PM does manager work for the + // projects they manage, so "the project's manager" is the person to chase, not "a PM". + private val DOER_ORDER = listOf(Role.PM, Role.HR, Role.ADMIN, Role.USER) + private val DOER_NAMES = mapOf( + Role.PM to "the project's manager", + Role.HR to "HR", + Role.ADMIN to "an admin", + Role.USER to "anybody", + ) + + val GET_APP_GUIDE_SPEC = BuddyToolSpecDto( + name = GET_APP_GUIDE, + description = "How the Sprintstart app itself works for the person you are talking to: the " + + "pages they can open, where each one is (the sidebar entry or the card that leads " + + "there), what it is for and how to do things on it — create a project role, choose a " + + "member's role, connect a source, write arrival steps, set a GitHub username — plus " + + "who to ask for what they cannot do themselves, and which page they are looking at " + + "right now. Call it for any 'where do I…', 'how do I … here', 'where is …' or " + + "'what is this page' question about the app. `search_docs` describes the project the " + + "team works on, not this app, so never answer these from it. Takes no arguments — it " + + "always reads the caller.", + parameters = buildJsonObject { + put("type", "object") + put("properties", buildJsonObject { }) + }, + ) + } +} diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt index 6ab7ff613..6c611909e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyService.kt @@ -273,6 +273,7 @@ class BuddyService( content: String, capabilitiesEnabled: Boolean = true, filters: BuddySessionFilters?, + currentPage: String? = null, ): Flow { val userId = resolveUserId(authId) if (sessionId == null) { @@ -373,7 +374,7 @@ class BuddyService( next.add( BuddyAgentMessageDto( role = "tool", - content = runToolCall(call, userId), + content = runToolCall(call, userId, currentPage), toolCallId = call.id, ), ) @@ -463,6 +464,7 @@ class BuddyService( private suspend fun FlowCollector.runToolCall( call: BuddyToolCallDto, userId: UUID, + currentPage: String?, ): String = if (buddyActionService.isAction(call.name)) { val outcome = buddyActionService.propose(call, userId) @@ -503,7 +505,7 @@ class BuddyService( outcome.toolResult } else { emit(BuddyStreamEvent(type = "tool_use", name = call.name, kind = "tool")) - buddyToolExecutor.execute(call, userId) + buddyToolExecutor.execute(call, userId, currentPage) } private fun resolveUserId(authId: String): UUID = diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt index a75a90f40..fcd744b1c 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionService.kt @@ -106,6 +106,12 @@ class BuddySuggestionService( label = "Who's on my team?", question = "Who else is on my project?", ), + // Last, because it is about the app rather than the hire's onboarding. "This page" is + // the one the chip was pressed on: the client sends it with the message. + BuddyAppGuideTools.GET_APP_GUIDE to BuddySuggestionResponse( + label = "What can I do here?", + question = "What can I do on this page?", + ), ) } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt index 0b109c0f5..dab8515f6 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamService.kt @@ -139,6 +139,7 @@ class BuddyTeamService( projectId: UUID, content: String, capabilitiesEnabled: Boolean = true, + currentPage: String? = null, ): Flow { val userId = authorize(authId, projectId) val session = getOrCreateSession(userId, projectId) @@ -154,7 +155,12 @@ class BuddyTeamService( BuddyTeamMessage(session = session, role = BuddyMessageRole.USER, content = content), ) - val context = TeamToolContext(userId = userId, authId = authId, projectId = projectId) + val context = TeamToolContext( + userId = userId, + authId = authId, + projectId = projectId, + currentPage = currentPage, + ) return flow { var messages = history + BuddyAgentMessageDto(role = "user", content = content) diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt index 2066baa19..1c22c09ac 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamTools.kt @@ -42,6 +42,7 @@ class BuddyTeamTools( private val projectMembershipApi: ProjectMembershipApi, private val areaToolsProvider: ObjectProvider, private val buddyProposalService: BuddyProposalService, + private val buddyAppGuideTools: BuddyAppGuideTools, ) { // Resolved lazily: an area's tools may themselves depend on services that depend on this one. private val areaTools: Map by lazy { @@ -63,6 +64,9 @@ class BuddyTeamTools( add(GET_TEAM_ATTENTION_SPEC) add(FIND_MEMBER_SPEC) add(GET_MEMBER_PROGRESS_SPEC) + // A read about the app, not about the team, so outside the areas: "where do I set up + // roles?" has to be answerable before anything is opened. + add(BuddyAppGuideTools.GET_APP_GUIDE_SPEC) if (openableAreas().isNotEmpty()) { add(openAreaSpec()) } @@ -124,6 +128,7 @@ class BuddyTeamTools( GET_TEAM_ATTENTION -> teamAttention(context.projectId) FIND_MEMBER -> findMember(context.projectId, call.stringArg("query")) GET_MEMBER_PROGRESS -> memberProgress(context.projectId, call.stringArg("member_id")) + BuddyAppGuideTools.GET_APP_GUIDE -> buddyAppGuideTools.guideFor(context.userId, context.currentPage) else -> areaTools.values.firstOrNull { it.handles(call.name) }?.execute(call, context) ?: "Unknown tool: ${call.name}." } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt index 13715ebe5..71ac5feda 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutor.kt @@ -43,6 +43,7 @@ class BuddyToolExecutor( private val arrivalStepService: ArrivalStepService, private val competencyPlacementService: CompetencyPlacementService, private val buddyPathTools: BuddyPathTools, + private val buddyAppGuideTools: BuddyAppGuideTools, ) { /** * The backend tools the AI reasoner is told it may call, for this hire. @@ -91,6 +92,8 @@ class BuddyToolExecutor( add(GET_SUGGESTED_TASKS_SPEC) add(SEARCH_CANONICAL_ANSWERS_SPEC) add(GET_TEAMMATES_SPEC) + // Always mounted: everybody uses the app, and the guide says what this caller can open. + add(BuddyAppGuideTools.GET_APP_GUIDE_SPEC) addAll(buddyBoardTools.toolSpecs()) } @@ -138,8 +141,12 @@ class BuddyToolExecutor( return "Never placed on (a short chat would settle these, and you may offer one): $named" } - /** Executes [call] on behalf of [userId], returning a plain-text result for the model. */ - fun execute(call: BuddyToolCallDto, userId: UUID): String = + /** + * Executes [call] on behalf of [userId], returning a plain-text result for the model. + * + * @param currentPage The app path the caller was on when they spoke, for the app guide. + */ + fun execute(call: BuddyToolCallDto, userId: UUID, currentPage: String? = null): String = when { buddyBoardTools.handles(call.name) -> buddyBoardTools.execute(call, userId) buddyPathTools.handles(call.name) -> buddyPathTools.execute(userId) @@ -152,6 +159,7 @@ class BuddyToolExecutor( GET_SUGGESTED_TASKS -> getSuggestedTasks(userId) SEARCH_CANONICAL_ANSWERS -> searchCanonicalAnswers(userId, call.stringArg("query")) GET_TEAMMATES -> getTeammates(userId) + BuddyAppGuideTools.GET_APP_GUIDE -> buddyAppGuideTools.guideFor(userId, currentPage) else -> "Unknown tool: ${call.name}." } } diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt index 330e8845b..53b29400e 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/TeamAreaTools.kt @@ -29,6 +29,8 @@ data class TeamToolContext( val userId: UUID, val authId: String, val projectId: UUID, + /** The app path the manager was on when they spoke, for the app guide; null when not sent. */ + val currentPage: String? = null, ) /** diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt index 651f3929a..a44426c09 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/external/UserApi.kt @@ -2,6 +2,7 @@ package com.sprintstart.sprintstartbackend.user.external import com.sprintstart.sprintstartbackend.user.external.dto.UserDto import com.sprintstart.sprintstartbackend.user.external.enums.GithubLoginVerification +import com.sprintstart.sprintstartbackend.user.external.enums.Role import org.springframework.data.domain.Page import org.springframework.data.domain.Pageable import java.time.Instant @@ -108,6 +109,18 @@ interface UserApi { */ fun isAdmin(authId: String): Boolean + /** + * Returns the one permission group a user's pages are gated by: the highest of their roles. + * + * The same collapse the user response's `permissionGroup` makes, and the value the frontend's + * access policy decides every route on — so a module describing the app to somebody can describe + * exactly the pages they can open. + * + * @param userId Internal SprintStart user identifier. + * @return The highest role the user holds, or `null` when the user does not exist. + */ + fun getPermissionGroup(userId: UUID): Role? + /** * Returns the GitHub account a user contributes as, if they have declared one. * diff --git a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt index 95a8bba90..741369524 100644 --- a/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt +++ b/src/main/kotlin/com/sprintstart/sprintstartbackend/user/service/UserApiService.kt @@ -15,6 +15,7 @@ import com.sprintstart.sprintstartbackend.user.model.entity.Project import com.sprintstart.sprintstartbackend.user.model.entity.ProjectRole import com.sprintstart.sprintstartbackend.user.model.entity.ProjectUserAssignment import com.sprintstart.sprintstartbackend.user.model.entity.User +import com.sprintstart.sprintstartbackend.user.model.mapper.effectivePermissionGroup import com.sprintstart.sprintstartbackend.user.model.mapper.toUserApiDto import com.sprintstart.sprintstartbackend.user.repository.ProjectRepository import com.sprintstart.sprintstartbackend.user.repository.UserRepository @@ -312,6 +313,14 @@ class UserApiService( .map { user -> Role.ADMIN in user.roles } .orElse(false) + @Transactional(readOnly = true) + @Tracked("Reading user permission group") + override fun getPermissionGroup(userId: UUID): Role? = + userRepository + .findById(userId) + .map { it.effectivePermissionGroup() } + .orElse(null) + override fun canManageProject(authId: String, projectId: UUID): Boolean { val user = userRepository.findByAuthId(authId).orElse(null) ?: return false diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt index 615298b75..4a4473fda 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/controller/BuddyControllerTest.kt @@ -397,6 +397,47 @@ class BuddyControllerTest( } } + /** The page the hire was on reaches the buddy, so "where is this here?" has a "here". */ + @Test + fun `sendMessageForMe passes the current page through`() { + val sessionId = UUID.randomUUID() + coEvery { + buddyService.sendMessageForMe( + authId, + sessionId, + "where are roles?", + true, + null, + "/team-management", + ) + } returns + flowOf(BuddyStreamEvent(type = "done")) + + val asyncResult = mockMvc + .perform( + post("/api/v1/onboarding/me/buddy/messages") + .with(userJwt) + .contentType(MediaType.APPLICATION_JSON) + .content( + """{"sessionId":"$sessionId","content":"where are roles?","currentPage":"/team-management"}""", + ), + ).andExpect(request().asyncStarted()) + .andReturn() + + mockMvc.perform(asyncDispatch(asyncResult)).andExpect(status().isOk) + + coVerify { + buddyService.sendMessageForMe( + authId, + sessionId, + "where are roles?", + true, + null, + "/team-management", + ) + } + } + @Test fun `sendMessageForMe should stream tokens and done`() { val events = listOf( diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt new file mode 100644 index 000000000..9fd443cf4 --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/AppGuideTest.kt @@ -0,0 +1,83 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.user.external.enums.Role +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test + +class AppGuideTest { + /** + * Pinned on purpose. The guide is a hand-written copy of the frontend's routes, so adding, + * renaming or removing a page has to be a deliberate edit here — check the frontend's + * `AppRouter.tsx` and `accessPolicy.ts` when this fails, not just the expected list. + */ + @Test + fun `describes exactly these routes`() { + assertThat(AppGuide.pages.map { it.path }).containsExactly( + "/", + "/board", + "/chat", + "/buddy", + "/knowledge-base", + "/onboarding", + "/settings", + "/pm-dashboard", + "/team-management", + "/team/{member_id}", + "/insights/onboarding", + "/insights/faq", + "/insights/knowledge-gaps", + "/insights/knowledge-requests", + "/data-ingestion", + "/blueprints", + "/hire-setup", + "/admin", + ) + } + + /** Anything else would render as a new tab to somebody else's site, or not as a link at all. */ + @Test + fun `every path is root-relative`() { + AppGuide.pages.forEach { page -> + assertThat(page.path).startsWith("/").doesNotStartWith("//") + } + } + + /** + * A how-to's deep link must land on its own page — a link to another page's tab would send + * somebody to a page the guide never checked they can open. + */ + @Test + fun `every deep link stays on its own page`() { + AppGuide.pages.forEach { page -> + page.howTos.mapNotNull { it.link }.forEach { link -> + assertThat(link.substringBefore('?')).isEqualTo(page.path) + } + } + } + + /** The frontend only narrows PM access on pages that PM, HR and admins can all open. */ + @Test + fun `a manager-scoped page is open to PM, HR and admins`() { + AppGuide.pages.filter { it.managerScoped }.forEach { page -> + assertThat(page.roles).containsExactlyInAnyOrder(Role.PM, Role.HR, Role.ADMIN) + } + } + + @Test + fun `names the page behind a detail route`() { + assertThat(AppGuide.pageAt("/team/1234")?.name).isEqualTo("Team member") + assertThat(AppGuide.pageAt("/chat/abc")?.name).isEqualTo("Chat") + assertThat(AppGuide.pageAt("/blueprints/abc")?.name).isEqualTo("Blueprints") + } + + /** `/team-management` starts with `/team`, but not with the member page's `/team/`. */ + @Test + fun `does not mistake team management for a member page`() { + assertThat(AppGuide.pageAt("/team-management")?.name).isEqualTo("Team") + } + + @Test + fun `knows nothing about a route it does not describe`() { + assertThat(AppGuide.pageAt("/nowhere")).isNull() + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt new file mode 100644 index 000000000..75a6943fa --- /dev/null +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyAppGuideToolsTest.kt @@ -0,0 +1,168 @@ +package com.sprintstart.sprintstartbackend.onboarding.service + +import com.sprintstart.sprintstartbackend.user.external.UserApi +import com.sprintstart.sprintstartbackend.user.external.dto.ProjectDto +import com.sprintstart.sprintstartbackend.user.external.dto.UserDto +import com.sprintstart.sprintstartbackend.user.external.enums.Role +import io.mockk.every +import io.mockk.mockk +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import java.util.Optional +import java.util.UUID + +class BuddyAppGuideToolsTest { + private val userId = UUID.randomUUID() + private val managedProject = ProjectDto(projectId = UUID.randomUUID(), name = "Apollo", description = null) + private val otherProject = ProjectDto(projectId = UUID.randomUUID(), name = "Gemini", description = null) + + private val userApi: UserApi = mockk { + every { getAuthIdByUserId(userId) } returns Optional.of("auth|me") + every { getUsersByIds(listOf(userId)) } returns listOf( + UserDto( + id = userId, + username = "me", + firstname = "Sam", + lastname = "Lee", + avatarUrl = null, + profileIcon = null, + projects = setOf(managedProject, otherProject), + projectRoles = emptyList(), + ), + ) + every { canManageProject("auth|me", any()) } returns false + } + + private val tools = BuddyAppGuideTools(userApi) + + private fun guideAs(role: Role?, currentPage: String? = null): String { + every { userApi.getPermissionGroup(userId) } returns role + return tools.guideFor(userId, currentPage) + } + + /** The part of the guide that lists pages the caller may be sent to. */ + private fun openable(guide: String): String = + guide.substringAfter("Pages they can open").substringBefore("Only somebody else") + + /** The part that lists what somebody else has to do. */ + private fun elsewhere(guide: String): String = + guide.substringAfter("Only somebody else", missingDelimiterValue = "") + + @Test + fun `offers a hire their own pages and never a manager page`() { + val guide = guideAs(Role.USER) + + assertThat(openable(guide)).contains("Board (/board)", "OnBoarding (/onboarding)") + assertThat(openable(guide)).doesNotContain("/pm-dashboard", "/team-management", "/admin") + } + + /** The question this tool exists for: a PM asking how to create a project. */ + @Test + fun `tells a PM that only an admin creates a project`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + val guide = guideAs(Role.PM) + + assertThat(openable(guide)).doesNotContain("/admin", "New Project") + assertThat(elsewhere(guide)).contains("create a project: an admin, on Access Management") + } + + @Test + fun `shows a managing PM where roles are set up and chosen`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + val guide = guideAs(Role.PM) + + assertThat(guide).contains("They manage: Apollo.") + assertThat(guide).doesNotContain("Gemini") + assertThat(openable(guide)) + .contains("Team (/team-management) — PM Dashboard (sidebar) → the \"Team\" tab") + .contains("to create a project role: Team → \"Roles\"") + .contains("to choose or change a member's role") + } + + /** The frontend gates manager pages on the selected project, so "manages one" is not enough. */ + @Test + fun `tells a managing PM that manager pages follow the selected project`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + assertThat(guideAs(Role.PM)).contains( + "They manage: Apollo. The manager pages (PM Dashboard and its tabs, Data Ingestion, " + + "Blueprints) show only while one of these is the selected project.", + ) + } + + /** The link opens the tab the steps talk about, not the page's default one. */ + @Test + fun `hands over the deep link that opens the right tab`() { + every { userApi.canManageProject("auth|me", managedProject.projectId) } returns true + + assertThat(openable(guideAs(Role.PM))) + .contains("\"Create role\" (name and what it is responsible for) (link: /team-management?tab=roles)") + .contains("Hire Setup → \"Arrival\" tab (link: /hire-setup?tab=arrival)") + } + + /** The frontend hides every manager page from a PM who manages nothing; so must the guide. */ + @Test + fun `keeps manager pages from a PM who manages no project, and says why`() { + val guide = guideAs(Role.PM) + + assertThat(openable(guide)).doesNotContain("/pm-dashboard", "/team-management") + assertThat(guide).contains("They manage no project yet.") + assertThat(guide).contains("manage no project, so the manager pages") + } + + /** HR opens Access Management but may not press "New Project" — the backend is admin-only. */ + @Test + fun `sends HR to an admin for what only an admin may do on a page HR can open`() { + val guide = guideAs(Role.HR) + + assertThat(openable(guide)).contains("Access Management (/admin)").doesNotContain("New Project") + assertThat(elsewhere(guide)).contains("create a project: an admin") + // The Skills tab is hidden from HR in the frontend, and every skill-pool write 403s for them. + assertThat(openable(guide)).doesNotContain("New skill") + assertThat(elsewhere(guide)).contains("retire a skill in the organisation-wide pool: an admin") + } + + @Test + fun `gives an admin everything and nothing to ask anybody else`() { + val guide = guideAs(Role.ADMIN) + + assertThat(openable(guide)).contains("to create a project: \"Projects\" tab → \"New Project\"") + assertThat(elsewhere(guide)).isEmpty() + } + + @Test + fun `reads an unknown user as a plain member`() { + val guide = guideAs(null) + + assertThat(guide).contains("Who is asking: a team member.") + assertThat(openable(guide)).doesNotContain("/pm-dashboard") + } + + @Test + fun `names the page the caller is on, ignoring the query`() { + val guide = guideAs(Role.ADMIN, currentPage = "/team/42?tab=path#top") + + assertThat(guide).contains("They are looking at: Team member (/team/{member_id}).") + } + + @Test + fun `admits a page it does not describe`() { + assertThat(guideAs(Role.USER, currentPage = "/skill-wizard")) + .contains("They are on a page this guide does not describe.") + } + + /** The value is the client's word; anything that is not an app path is dropped, never echoed. */ + @Test + fun `drops a current page that is not an app path`() { + val guide = guideAs(Role.USER, currentPage = "javascript:alert(1)") + + assertThat(guide).doesNotContain("javascript", "They are looking at", "They are on") + } + + @Test + fun `says nothing about the current page when none was sent`() { + assertThat(guideAs(Role.USER)).doesNotContain("They are looking at", "They are on") + } +} diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt index 263327e0a..043a6e872 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddySuggestionServiceTest.kt @@ -47,6 +47,13 @@ class BuddySuggestionServiceTest { assertThat(service.forHire(userId).map { it.label }).contains("Where am I on my path?") } + @Test + fun `offers to explain the page the hire is on`() { + mounted(BuddyAppGuideTools.GET_APP_GUIDE) + + assertThat(service.forHire(userId).single().question).isEqualTo("What can I do on this page?") + } + /** * The whole point of deriving rather than listing: a chip appears exactly when its tool does. * Unlike the tool, a chip is something the hire *sees*, so getting this wrong is louder. diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt index 109b5d4a1..9b1b319a2 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyTeamToolsTest.kt @@ -34,6 +34,7 @@ class BuddyTeamToolsTest { private val projectMembershipApi: ProjectMembershipApi = mockk() private val areaToolsProvider: ObjectProvider = mockk() private val buddyProposalService: BuddyProposalService = mockk() + private val buddyAppGuideTools: BuddyAppGuideTools = mockk() private val projectId = UUID.randomUUID() private val memberId = UUID.randomUUID() @@ -57,6 +58,7 @@ class BuddyTeamToolsTest { projectMembershipApi, areaToolsProvider, buddyProposalService, + buddyAppGuideTools, ) } @@ -111,9 +113,24 @@ class BuddyTeamToolsTest { BuddyTeamTools.GET_TEAM_ATTENTION, BuddyTeamTools.FIND_MEMBER, BuddyTeamTools.GET_MEMBER_PROGRESS, + BuddyAppGuideTools.GET_APP_GUIDE, ) } + @Test + fun `answers the app guide for the manager and the page they are on`() { + every { buddyAppGuideTools.guideFor(context.userId, "/pm-dashboard") } returns "the guide" + val tools = tools() + + val result = tools.execute( + call(BuddyAppGuideTools.GET_APP_GUIDE), + context.copy(currentPage = "/pm-dashboard"), + readNames(tools), + ) + + assertThat(result).isEqualTo("the guide") + } + @Test fun `mounts open_area once an area has tools, and the area's tools only after it is opened`() { val tools = tools(knowledgeArea) diff --git a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt index a1799d38f..8d9e675fc 100644 --- a/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt +++ b/src/test/kotlin/com/sprintstart/sprintstartbackend/onboarding/service/BuddyToolExecutorTest.kt @@ -40,6 +40,7 @@ class BuddyToolExecutorTest { every { getGithubLoginByUserId(any()) } returns "sam" } private val buddyBoardTools: BuddyBoardTools = mockk(relaxed = true) + private val buddyAppGuideTools: BuddyAppGuideTools = mockk() private val artifactIngestionApi: ArtifactIngestionApi = mockk() private val projectMembershipApi: ProjectMembershipApi = mockk() @@ -79,6 +80,7 @@ class BuddyToolExecutorTest { // Pathless by default: every case here is about a tool that reads something other than the // onboarding path, and "no path" is what keeps the path tool out of their expectations. buddyPathTools, + buddyAppGuideTools, ) private val userId = UUID.randomUUID() @@ -231,9 +233,23 @@ class BuddyToolExecutorTest { "get_suggested_tasks", "search_canonical_answers", "get_teammates", + "get_app_guide", ) } + @Test + fun `answers the app guide for the caller and the page they are on`() { + every { buddyAppGuideTools.guideFor(userId, "/team-management") } returns "the guide" + + val result = executor.execute( + BuddyToolCallDto(id = "c1", name = "get_app_guide"), + userId, + currentPage = "/team-management", + ) + + assertThat(result).isEqualTo("the guide") + } + /** * Asserted on the real mounting rather than a stubbed tool list. Every other test of what the * buddy offers a hire goes through a mocked spec list, so the one thing that decides whether a