diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index 0f728df41f..a74c4fe8fb 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -2265,13 +2265,9 @@ export class AIChatManager { this.systemMessage = { ...target, content: `${target.content}\n\n${section}` } } - // Resolve what the user may do in the workspace this send targets, so the toolset - // never advertises a tool whose every call the backend would refuse. Deliberately - // re-resolved per send rather than cached for the session's life: a role change (or - // a transient `whoami` failure, which resolves fail-open) must take effect on the - // next message, not only on a workspace switch. - // - // Only sessions are filtered; the global side-panel chat keeps the full toolset. + // Re-resolved per send rather than cached for the session's life, so a role change — + // or a transient `whoami` failure, which resolves fail-open — takes effect on the next + // message rather than only on a workspace switch. Only sessions are filtered. private resolveSessionAccessForSend = async (workspace: string) => { if (!this.isSessionChat || !workspace) { this.sessionAccess = undefined @@ -2721,12 +2717,10 @@ export class AIChatManager { base = self.planMode.decorateSystemMessage(base) return base }, - // The one place every tool source converges — the static global set, the - // pipeline and MCP tools `configureGlobalMode` appends, and plan mode's, - // added here. Filtering here rather than at `globalToolsFor` is what makes - // the capability check cover the dynamic sources too, and the array is both - // advertised to the model and used to dispatch the call, so a withheld tool - // is unreachable rather than merely unlisted. + // The one place every tool source converges, which is why the capability + // filter belongs here and not in `globalToolsFor`. This same array both + // advertises the tools and dispatches the calls, so a withheld tool is + // unreachable rather than merely unlisted. get tools() { return filterSessionTools([...self.tools, ...self.planMode.tools], self.sessionAccess) }, diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 31e24157b6..6e6aeb42d7 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -1207,7 +1207,11 @@ type FolderPromptContext = { folders?: string[]; foldersRead?: string[]; isAdmin // non-exhaustive hint alongside permission-agnostic guidance (the complete set // needs a folder-listing tool — follow-up). // Capped so a folder-heavy workspace can't dominate the prompt. -function buildFolderGuidance(username: string, ctx?: FolderPromptContext): string { +function buildFolderGuidance( + username: string, + ctx?: FolderPromptContext, + canCreateFolder: boolean = true +): string { if (!ctx) return '' const MAX = 40 const writable = ctx.folders ?? [] @@ -1223,7 +1227,7 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin writable.length > 0 ? ` Folders here include ${fmt(writable)} (you can also write to others not listed).` : '' - return `- As a workspace admin you can write to any existing folder.${known} If the user names a folder, use it; if they explicitly ask for a new folder, create it with \`create_folder\`; otherwise ask them which folder to use rather than guessing or creating one unprompted.` + return `- As a workspace admin you can write to any existing folder.${known} If the user names a folder, use it;${canCreateFolder ? ' if they explicitly ask for a new folder, create it with `create_folder`;' : ''} otherwise ask them which folder to use rather than guessing${canCreateFolder ? ' or creating one unprompted' : ''}.` } // Everything below states the writable set as fact, including the empty case, so an // unresolved role has to say nothing at all: "you have no shared folders" is a claim, @@ -1234,11 +1238,15 @@ function buildFolderGuidance(username: string, ctx?: FolderPromptContext): strin const lines: string[] = [] if (writable.length > 0) { lines.push( - `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path. Use \`create_folder\` only when the user explicitly asks for a new folder.` + `- Folders you can write to in this workspace: ${fmt(writable)}. For shared/team work, pick the one whose purpose matches the request; if none clearly fits, ask which folder to use (askUserQuestion) rather than inventing a path.${canCreateFolder ? ' Use `create_folder` only when the user explicitly asks for a new folder.' : ''}` ) } else { lines.push( - `- You have no shared folders you can write to in this workspace, so use \`u/${username}/\`. If the user explicitly asks for a shared folder, create one with \`create_folder\` (you become an owner); otherwise ask before placing shared work rather than inventing an \`f//...\` path.` + `- You have no shared folders you can write to in this workspace, so use \`u/${username}/\`. ${ + canCreateFolder + ? 'If the user explicitly asks for a shared folder, create one with `create_folder` (you become an owner); otherwise ask' + : 'If the user explicitly asks for a shared folder, say plainly that you cannot create one here; otherwise ask' + } before placing shared work rather than inventing an \`f//...\` path.` ) } if (readOnly.length > 0) { @@ -1266,7 +1274,7 @@ const buildGlobalSystemPrompt = ( // Each gated block carries its own leading newline, so dropping one leaves no blank // line behind and a full-access prompt is byte-for-byte the ungated text. const when = (cond: boolean, block: string) => (cond ? block : '') - const folderGuidance = buildFolderGuidance(username, folderCtx) + const folderGuidance = buildFolderGuidance(username, folderCtx, canDeploy) const folderGuidanceBlock = folderGuidance ? `\n${folderGuidance}` : '' // `previewTools` doubles as "this is a session chat" — sessions are the only // chats that get the preview tool set. The alpha heads-up only makes sense @@ -1313,7 +1321,10 @@ Path conventions: - \`u/${username}/\` — your personal scope. Default for ad-hoc, exploratory, or scratch work. - \`f//\` — a shared folder scope; the must already exist (a bare \`f/\` with no folder segment is INVALID and will fail). - If the user supplies a fully qualified \`f//...\` path, use that exact path; they have already chosen the folder. Do not ask for folder confirmation or substitute a \`u/${username}/...\` path unless a tool rejects it. -- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/\`. Never invent an \`f//...\` path for a folder that does not exist; create one with \`create_folder\` only when the user explicitly asks for a new folder.${folderGuidanceBlock}` +- Default a bare name with no namespace prefix (e.g. "create a flow called myflow") to \`u/${username}/\`. Never invent an \`f//...\` path for a folder that does not exist${when( + canDeploy, + '; create one with `create_folder` only when the user explicitly asks for a new folder' + )}.${folderGuidanceBlock}` )} Rules:${when( @@ -1380,7 +1391,7 @@ ${pipelineBullet}` canWriteDraft, ` - After writing or substantially editing a script / flow / app draft, show it via open_preview(kind, path) so the user sees the editor and live preview right next to the chat. First check whether it is already shown: if unsure, call get_preview_status. Only call open_preview (or offer to) when no preview is open or it is showing a different item — don't re-open a preview already showing the item you just edited. -- Building a data pipeline: call open_preview(kind="pipeline", path="") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty or not-yet-created folder is fine (create_folder first if needed, then open it). Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.` +- Building a data pipeline: call open_preview(kind="pipeline", path="") as the FIRST step, before creating any node — this opens the pipeline editor the user reviews in. path is the folder, not an item; an empty folder is fine${when(canDeploy, ', and a not-yet-created one too (create_folder first, then open it)')}. Opening it registers build_pipeline_node / edit_pipeline_node — use ONLY those to add or change pipeline nodes, never write_script for a pipeline node — they apply directly as unsaved drafts on the canvas (no separate accept/reject step) that the user reviews and deploys. Do not write pipeline scripts without first opening the editor.` )} - When debugging a running raw app, call get_app_runtime_logs to read the live preview's browser console output. It needs the raw app preview open (open_preview kind="raw_app"). - To inspect what actually rendered in a running raw app (verify an edit landed on screen, diagnose a blank/empty or wrong view, answer "what's showing"), use search_dom (regex over the live HTML) and read_dom (a line-numbered window). Pass a \`selector\` to scope to an element — prefer the selector from a DOM element chip the user attached — or omit it for the whole page. When a chip lists an \`app_path\`, pass it too so the RIGHT app is read (several previews can be open; a query without \`app_path\` hits the visible one). The DOM is read live and is never in context; no match means the element isn't rendered. Both need the raw app preview open. diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts index d38ff3da21..8ba28c51d4 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionAccess.ts @@ -75,23 +75,11 @@ export async function resolveSessionAccess(workspace: string): Promise { const readOnly = accessWith(['deploy']) expect(sessionToolAllowed('get_instructions', readOnly)).toBe(false) expect(sessionToolAllowed('search_npm_packages', readOnly)).toBe(false) - expect(sessionToolAllowed('create_folder', readOnly)).toBe(false) expect(sessionToolAllowed('search_docs', readOnly)).toBe(true) }) + // create_folder carries both axes: the backend runs it through check_deploy_rules, + // so drafting alone is not enough to make it usable. + it('withholds create_folder from a session that cannot deploy', () => { + expect(sessionToolAllowed('create_folder', accessWith(['write_draft', 'run_preview']))).toBe( + false + ) + expect(sessionToolAllowed('create_folder', accessWith(['write_draft', 'deploy']))).toBe(true) + expect(sessionToolAllowed('create_folder', accessWith(['deploy']))).toBe(false) + }) + // The prompt is documentation OF the toolset, so it must never name a tool the same // profile withheld — an instruction to call a tool the model was not given is what // produces invented calls and promises the chat cannot keep. diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts index b91728d1eb..5d330b7c24 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts @@ -92,9 +92,11 @@ export const SESSION_TOOL_POLICIES: Record = { get_trigger_schema: AUTHORING_AID, get_schedule_schema: AUTHORING_AID, get_db_schema: AUTHORING_AID, - // An operator's token may create a folder, but a folder exists to hold - // authored items, so it goes with them rather than with the reads. - create_folder: AUTHORING_AID, + // Folder creation runs through the backend's `check_deploy_rules` (folders.rs + // `create_folder`), so a workspace that blocks deploys refuses it too — hence + // `deploy`, even though a folder is not a deployed item. It is also useless + // without something to put in it, hence the authoring relevance. + create_folder: { requires: ['deploy'], relevance: 'authoring' }, // Ungated on purpose, for two reasons. Plan mode's deliverable is a plan artifact, // which is worth producing for someone else to execute even when this user can // change nothing themselves. And it is a posture the USER selects, so withholding