refactor: assemble session prompt and tools through one seam

This commit is contained in:
AlexRV12
2026-09-09 14:20:20 +02:00
parent 87aded86bf
commit 0bb7301d0b
6 changed files with 127 additions and 86 deletions
@@ -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 => {
@@ -416,7 +416,7 @@ const readWorkspaceItemSchema = z.object({
path: z
.string()
.describe(
'Workspace path of the item to read, or a hub/<version>/<app>/<name> path from search_hub_scripts to read a hub script.'
'Workspace path of the item to read, or a hub/<version>/<app>/<name> 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="<folder>") 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="<folder>") 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,
@@ -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<typeof prepareGlobalSystemMessage> {
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<any>[] {
return [
...globalToolsFor({ sessionPreview: opts.sessionPreview }),
...(opts.pipeline ? pipelineTools : []),
...createMcpTools(opts.mcpServers ?? [])
]
}
@@ -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
@@ -125,7 +125,9 @@ export const SESSION_TOOL_POLICIES: Record<string, SessionToolPolicy> = {
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,
@@ -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.'
)
// ----------------------------------------------------------------------------