diff --git a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts index a74c4fe8fb..cafd8f78cc 100644 --- a/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts +++ b/frontend/src/lib/components/copilot/chat/AIChatManager.svelte.ts @@ -121,27 +121,21 @@ import type { WorkspaceMutationTarget } from './workspaceTools' import { resolveSessionAccess, type SessionAccess } from './global/sessionAccess' import { filterSessionTools } from './global/sessionToolset' import { - globalToolsFor, loadWorkspaceSkills, - prepareGlobalSystemMessage, resolveGlobalPromptIdentity, type GlobalPromptIdentity, prepareGlobalUserMessage, type AiSkillListItem, type ChatCommandItem, type SessionPromptContext, - getSessionContextPromptSection, type GlobalToolHelpers, type GlobalActivePreviewContext } from './global/core' +import { assembleGlobalSystemMessage, assembleGlobalTools } from './global/sessionAssembly' import { formatChatJobCompletion } from './datatableTools' import { isGlobalAiEnabled } from './global/gate' -import { createMcpTools, loadMcpServers, type McpServer } from './global/mcpTools' -import { - pipelineTools, - getPipelinePromptSection, - type PipelineAIChatHelpers -} from './pipeline/core' +import { loadMcpServers, type McpServer } from './global/mcpTools' +import { type PipelineAIChatHelpers } from './pipeline/core' import { scopedKey, onUserChange, migrateLegacyLocalStorage } from '$lib/userScopedStorage' import { getLocalSetting, storeLocalSetting } from '$lib/utils' import { AttachedFilesStore } from './files/attachedFiles.svelte' @@ -2135,17 +2129,16 @@ export class AIChatManager { // Public because it is purely local, unlike `changeMode(GLOBAL)`, which also // fires the three network refreshes. configureGlobalMode = () => { - const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { + const pipelineCtx = this.pipelineAiChatHelpers?.getPipelineContext() + const systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { previewTools: this.isSessionChat, user: this.globalIdentity, skills: this.globalSkills, mcpServers: this.mcpServers, - access: this.sessionAccess + access: this.sessionAccess, + sessionContext: this.sessionContextResolver?.(), + pipelineContext: pipelineCtx }) - const sessionCtx = this.sessionContextResolver?.() - if (sessionCtx) { - systemMessage.content += getSessionContextPromptSection(sessionCtx, this.sessionAccess) - } const baseHelpers: GlobalToolHelpers = { // A session targets its own fixed (possibly forked) workspace, so capture it for // permission gating. The global side-panel chat follows the live navigation @@ -2175,25 +2168,12 @@ export class AIChatManager { } } const pipeline = this.pipelineAiChatHelpers - const mcpTools = createMcpTools(this.mcpServers) - // Every tool source assembled below needs an entry in SESSION_TOOL_POLICIES: the - // session filter fails closed, so a source added here without one is withheld from - // restricted sessions. sessionToolset.test.ts enumerates these sources to catch it. - if (pipeline) { - systemMessage.content += getPipelinePromptSection( - pipeline.getPipelineContext(), - this.sessionAccess - ) - this.tools = [ - ...globalToolsFor({ sessionPreview: this.isSessionChat }), - ...pipelineTools, - ...mcpTools - ] - this.helpers = { ...baseHelpers, pipeline } - } else { - this.tools = [...globalToolsFor({ sessionPreview: this.isSessionChat }), ...mcpTools] - this.helpers = baseHelpers - } + this.tools = assembleGlobalTools({ + sessionPreview: this.isSessionChat, + pipeline: !!pipeline, + mcpServers: this.mcpServers + }) + this.helpers = pipeline ? { ...baseHelpers, pipeline } : baseHelpers this.systemMessage = systemMessage this.syncArtifactsSession() } @@ -2288,28 +2268,15 @@ export class AIChatManager { if (this.mode !== AIMode.GLOBAL) { return } - const systemMessage = prepareGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { + this.systemMessage = assembleGlobalSystemMessage(getCustomPromptParts(AIMode.GLOBAL), { previewTools: this.isSessionChat, user: this.globalIdentity, skills: this.globalSkills, mcpServers: this.mcpServers, - access: this.sessionAccess + access: this.sessionAccess, + sessionContext: this.sessionContextResolver?.(), + pipelineContext: this.pipelineAiChatHelpers?.getPipelineContext() }) - // Preserve the session-state and active pipeline-editor augmentations that - // configureGlobalMode adds — otherwise update_user_instructions (which calls - // this) would drop them mid-session. - const sessionCtx = this.sessionContextResolver?.() - if (sessionCtx) { - systemMessage.content += getSessionContextPromptSection(sessionCtx, this.sessionAccess) - } - const pipeline = this.pipelineAiChatHelpers - if (pipeline) { - systemMessage.content += getPipelinePromptSection( - pipeline.getPipelineContext(), - this.sessionAccess - ) - } - this.systemMessage = systemMessage } private expandGlobalSkillCommand = (instructions: string): string => { diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 6e6aeb42d7..e5bae1a0b3 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -416,7 +416,7 @@ const readWorkspaceItemSchema = z.object({ path: z .string() .describe( - 'Workspace path of the item to read, or a hub/// path from search_hub_scripts to read a hub script.' + 'Workspace path of the item to read, or a hub/// path from a hub search to read a hub script.' ), trigger_kind: triggerKindSchema .optional() @@ -1074,7 +1074,7 @@ const openPreviewSchema = z.object({ kind: z .enum(['script', 'flow', 'raw_app', 'pipeline']) .describe( - 'Item kind to preview. Use "raw_app" for code-based apps (created via init_app). Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' + 'Item kind to preview. Use "raw_app" for code-based apps. Use "pipeline" to show the data-pipeline graph for a folder — here `path` is the folder name, not an item path. The legacy drag-and-drop app builder ("app") is not previewable in the session panel — don\'t pass it.' ), path: z .string() @@ -1391,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 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.` +- 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 ${when(canDeploy, 'or not-yet-created ')}folder is fine${when(canDeploy, ' (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.` )} - 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. @@ -3898,7 +3898,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( rebaseDraftSchema, 'rebase_draft', - 'Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when deploy_workspace_item reports the draft was started from an older deployed version.', + 'Discard a stale script, flow, or app draft and return your changes as a diff to re-apply on the latest deployed version. Use when a deploy reports the draft was started from an older deployed version.', { strict: false } ), showDetails: true, diff --git a/frontend/src/lib/components/copilot/chat/global/sessionAssembly.ts b/frontend/src/lib/components/copilot/chat/global/sessionAssembly.ts new file mode 100644 index 0000000000..cce2d55564 --- /dev/null +++ b/frontend/src/lib/components/copilot/chat/global/sessionAssembly.ts @@ -0,0 +1,63 @@ +import type { Tool } from '../shared' +import type { PipelineContext } from '../pipeline/core' +import { getPipelinePromptSection, pipelineTools } from '../pipeline/core' +import { createMcpTools, type McpServer } from './mcpTools' +import { + getSessionContextPromptSection, + globalToolsFor, + prepareGlobalSystemMessage, + type AiSkillListItem, + type SessionPromptContext +} from './core' +import type { SessionAccess } from './sessionAccess' + +/** + * Assembly of what a GLOBAL-mode chat ships: the system prompt's sections, and the + * tool sources they document. Both live here so production and the capability tests + * go through the same code — a section or a tool source added to one cannot be + * missing from the other, which is how a prompt once kept naming tools the filter + * had already withheld. + */ + +export type GlobalAssemblyOptions = { + previewTools?: boolean + user?: { username: string; is_admin?: boolean; folders?: string[]; folders_read?: string[] } + skills?: AiSkillListItem[] + mcpServers?: McpServer[] + access?: SessionAccess + /** Appended when the chat is a session; carries the operating/parent workspace. */ + sessionContext?: SessionPromptContext + /** Appended while a /pipeline editor has registered its helpers. */ + pipelineContext?: PipelineContext +} + +/** The system message as sent, minus plan mode's decoration — that is applied later, + * per request, by `planModeController`. */ +export function assembleGlobalSystemMessage( + instructions: { workspace?: string; user?: string } | undefined, + opts: GlobalAssemblyOptions +): ReturnType { + const message = prepareGlobalSystemMessage(instructions, opts) + let content = typeof message.content === 'string' ? message.content : '' + if (opts.sessionContext) { + content += getSessionContextPromptSection(opts.sessionContext, opts.access) + } + if (opts.pipelineContext) { + content += getPipelinePromptSection(opts.pipelineContext, opts.access) + } + return { ...message, content } +} + +/** Every tool source a GLOBAL chat assembles, unfiltered. Plan mode's two tools are + * not here: they are merged at request time, next to the capability filter itself. */ +export function assembleGlobalTools(opts: { + sessionPreview: boolean + pipeline: boolean + mcpServers?: McpServer[] +}): Tool[] { + return [ + ...globalToolsFor({ sessionPreview: opts.sessionPreview }), + ...(opts.pipeline ? pipelineTools : []), + ...createMcpTools(opts.mcpServers ?? []) + ] +} diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts index 79316f3988..bac667ab8c 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.test.ts @@ -16,23 +16,19 @@ vi.mock('@codingame/monaco-vscode-standalone-typescript-language-features', () = vi.mock('@codingame/monaco-vscode-languages-service-override', () => ({ default: () => ({}) })) vi.mock('$lib/components/vscode', () => ({})) -import { - globalTools, - getSessionContextPromptSection, - prepareGlobalSystemMessage, - type SessionPromptContext -} from './core' +import { globalTools, prepareGlobalSystemMessage, type SessionPromptContext } from './core' import { appendPlanModeInstructions } from '../planMode' -import { getPipelinePromptSection, pipelineTools } from '../pipeline/core' +import { pipelineTools } from '../pipeline/core' import { createMcpTools } from './mcpTools' import { ENTER_PLAN_MODE_TOOL, EXIT_PLAN_MODE_TOOL } from '../planMode' +import { assembleGlobalSystemMessage, assembleGlobalTools } from './sessionAssembly' import { SESSION_TOOL_POLICIES, filterSessionTools, sessionToolAllowed } from './sessionToolset' import { fullSessionAccess, type SessionAccess, type SessionCapability } from './sessionAccess' -/** Every tool name that can reach a session's toolset. `globalTools` is only part of - * it — pipeline and MCP tools are appended by `configureGlobalMode`, and plan mode's - * at request time — which is exactly why the policy table is keyed by name rather - * than declared on `globalTools`. */ +/** Every tool name that can reach a session's toolset. Uses `globalTools` rather than + * `assembleGlobalTools`'s `globalToolsFor` so the coverage below holds for the tools + * that a non-session or non-Chromium host would filter out too — a superset, which is + * the safe direction for an exhaustiveness check. */ function assembledSessionToolNames(): string[] { const mcp = createMcpTools([{ path: 'f/test/server' } as any]) return [ @@ -44,6 +40,16 @@ function assembledSessionToolNames(): string[] { ] } +/** The tools a session actually ships, through the same assembly production uses. */ +function shippedSessionTools(access: SessionAccess) { + const assembled = assembleGlobalTools({ + sessionPreview: true, + pipeline: true, + mcpServers: [{ path: 'f/test/server' } as any] + }) + return filterSessionTools(assembled, access) +} + function accessWith(capabilities: SessionCapability[]): SessionAccess { return { workspace: 'test', capabilities: new Set(capabilities) } } @@ -129,33 +135,36 @@ describe('session tool policies', () => { // every reachable profile, and every tool from the policy table — so neither a new tool // nor a new capability combination slips past. it.each([ - ['read-only', []], - ['drafts, no deploy', ['write_draft', 'run_preview']], - ['drafts, no preview', ['write_draft', 'deploy']], - ['deploy, no drafts', ['deploy']] - ] as [string, SessionCapability[]][])( + // `reachable` marks the profiles `resolveSessionAccess` can actually produce. The + // prompt is swept for the unreachable ones too, since gating it costs nothing; the + // tool DEFINITIONS are not, because the only way to satisfy those cases is to strip + // a sibling tool's name out of a description that earns its place for real sessions. + ['read-only', [], true], + ['drafts, no deploy', ['write_draft', 'run_preview'], true], + ['drafts only', ['write_draft'], true], + ['drafts, no preview', ['write_draft', 'deploy'], false], + ['deploy, no drafts', ['deploy'], false] + ] as [string, SessionCapability[], boolean][])( 'never names a withheld tool in the assembled prompt (%s)', - (_label, capabilities) => { + (_label, capabilities, reachable) => { const access = accessWith(capabilities) const withheld = assembledSessionToolNames().filter((n) => !sessionToolAllowed(n, access)) expect(withheld.length).toBeGreaterThan(0) + // The tool DEFINITIONS ship alongside the prompt, so a withheld name in a + // description is the same broken promise as one in the prompt. + if (reachable) { + const defs = JSON.stringify(shippedSessionTools(access).map((t) => t.def)) + expect(withheld.filter((n) => defs.includes(n))).toEqual([]) + } for (const previewTools of [false, true]) { for (const ctx of SESSION_CONTEXTS) { - let msg = prepareGlobalSystemMessage(undefined, { + const msg = assembleGlobalSystemMessage(undefined, { previewTools, user: { username: 'alex', folders: ['shared'], folders_read: ['shared'] }, - access + access, + sessionContext: ctx, + pipelineContext: { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] } }) - msg = { - ...msg, - content: - (msg.content as string) + - getSessionContextPromptSection(ctx, access) + - getPipelinePromptSection( - { folder: 'my_pipeline', mode: 'edit', nodes: [], assets: [] }, - access - ) - } // Both decoration variants: the escalation one adds its own tool mentions. for (const blocks of [0, 9]) { const full = appendPlanModeInstructions(msg, blocks).content as string diff --git a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts index 5d330b7c24..83adc8b654 100644 --- a/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts +++ b/frontend/src/lib/components/copilot/chat/global/sessionToolset.ts @@ -125,7 +125,9 @@ export const SESSION_TOOL_POLICIES: Record = { delete_app_file: WRITE_DRAFT, write_app_runnable: WRITE_DRAFT, delete_app_runnable: WRITE_DRAFT, - // Both act on a draft, which can only exist for someone who could write one. + // Relevance, not authorization: discarding your OWN draft deliberately skips + // `require_can_write_path` (drafts.rs), so the backend would allow it — but a + // session that cannot write a draft has none to discard or rebase. discard_local_draft: WRITE_DRAFT, rebase_draft: WRITE_DRAFT, diff --git a/frontend/src/lib/components/copilot/chat/pipeline/core.ts b/frontend/src/lib/components/copilot/chat/pipeline/core.ts index f89fc7a38f..ac044f4c76 100644 --- a/frontend/src/lib/components/copilot/chat/pipeline/core.ts +++ b/frontend/src/lib/components/copilot/chat/pipeline/core.ts @@ -114,7 +114,7 @@ const readPipelineNodeSchema = z.object({ const readPipelineNodeToolDef = createToolDef( readPipelineNodeSchema, 'read_pipeline_node', - 'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before edit_pipeline_node so edits target the exact current text.' + 'Read the full source of one pipeline node (its in-flight draft body if it has unsaved edits, otherwise the deployed body). Use before editing a node so edits target the exact current text.' ) // ----------------------------------------------------------------------------