diff --git a/CHANGELOG.md b/CHANGELOG.md index 566bafa..137641e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,28 @@ platform API version. ## [Unreleased] **Release:** this release must bump `DIALOGRAM_API_VERSION` to 0.9 and ship -with products built against it: its API removals (#127, #129) and the stock -bundle's dropping the agent workflow UI (below) need a product still at 0.8 -to fail its version check rather than run against what it no longer has. +with products built against it: its API removals (#127, #129), the stock +bundle's dropping the agent workflow UI (#137) and the server's no longer +answering agent discovery (#139) need a product still at 0.8 to fail its +version check rather than run against what it no longer has. ### Removed +- **Agent discovery and enrichment in the platform.** The server no longer + scans `.wf/skills`, `.claude/skills`, `~/.claude/skills` and the + `.claude/agents` directories when any product's diagram loads, no longer + puts the skills and agent presets it found on the model's root + (`wf:agentSkillsSnapshot`, `wf:claudeAgentsSnapshot`) or a skill status on + `@agent` nodes, and no longer rebuilds the model a second time when that + scan finds something. Its discovery requests and answers are gone with it + (`dialogram.requestAgentSkills` / `requestClaudeAgents`, + `dialogram.showAgentSkills` / `showClaudeAgents`, their handlers and the + client's caches), as is the agent workflow UI's copy in the client + (`agent-ui/`, `agentUiModule`, both unreleased). wfpy-ide asks its own + host for skills and presets (endrix/wfpy-ide#26, #27). The + `cal:agentSkillStatus` metadata key stays for now: wfpy-ide's Skill row + still writes it. + - **The `@path` annotation editor and its wizard template.** No producer has a `@path` decorator: wfpy sets a workflow's tool search paths with `@config(path=[...])`. The property panel's editor never rendered, and the @@ -67,13 +83,8 @@ to fail its version check rather than run against what it no longer has. passed to `startStockDiagram({ features })`; each keeps one contribution per name or id, the last binding winning, so a product can replace a stock one. wfpy's `@tool`/`@agent` editors, agent sections and - `@tool`/`@viewer` templates moved onto them (`src/agent-ui/`, #133–#135) - and are bound together by `agentUiModule`, imported by path as - `@dialogram/diagram-client/agent-ui/index` (#136). The stock bundle still - binds them; a product's bundle that loads `agentUiModule` too draws - everything once. On the way: a node's discovered skills are no longer - replaced by the model's older snapshot when discovery answers, and the - wizard escapes a newline or tab in what it quotes. + `@tool`/`@viewer` templates moved onto them (#133–#135), then into + wfpy-ide's own webview bundle (#136, #137, endrix/wfpy-ide#24, #27). - **A product whose CLI has no `run` says so: `cliRuns: false`.** The toolkit then registers no run driver and the chat offers no resume. The @@ -116,8 +127,8 @@ to fail its version check rather than run against what it no longer has. Agent Context), the wizard's `@tool` and `@viewer` templates and the handlers of the server's skill and agent-preset answers were wfpy's, yet every stock-bundle product showed the editors, the header's "Add @tool" and - "Add @agent" and the templates. They are `agentUiModule`, which wfpy-ide's - own webview bundle loads (endrix/wfpy-ide#24, merged). sv, vhdl, ndf and + "Add @agent" and the templates. They are wfpy-ide's code now, in its own + webview bundle (endrix/wfpy-ide#24, #27). sv, vhdl, ndf and calpy get the plain annotation card and only the wizard's "Custom annotation…"; none of them has `@agent` nodes, so Agent Tools, the Skill row and Agent Context never appeared for them. The stock bundle is 141 KB diff --git a/README.md b/README.md index bb5f363..c442872 100644 --- a/README.md +++ b/README.md @@ -323,11 +323,10 @@ consumer's profile input — never to extend the allow-list. bundle at build time; check the extension host console to confirm the running bundles are fresh. - `[dialogram perf] load #: acquire=… layout=… loadTotal=…` — - per-load phase attribution emitted by the diagram server, including which - enrichment phases were deferred off the first paint. Loads after the first - should be low-millisecond (memoized); expensive scans (caller references, - skills/agents enrichment) run bounded in the background and redeliver via - `refresh-*` model requests. + per-load phase attribution emitted by the diagram server, including whether + the caller-reference scan was deferred off the first paint. Loads after the + first should be low-millisecond (memoized); the caller-reference scan runs + bounded in the background and redelivers via a `refresh-*` model request. ## Documentation diff --git a/packages/diagram-client/README.md b/packages/diagram-client/README.md index ed6cc75..c3427e7 100644 --- a/packages/diagram-client/README.md +++ b/packages/diagram-client/README.md @@ -267,12 +267,10 @@ There is one template per annotation name, and a later binding replaces an earlier one. The wizard lists them before its own "Custom annotation…" entry (`custom`), which a template cannot replace. -**The agent workflow UI.** The stock bundle binds no editors, sections or -templates. wfpy's are `agentUiModule`, imported by path as -`@dialogram/diagram-client/agent-ui/index`: the `@tool` and `@agent` -editors, the agent sections (id `agent`: Basic Info's Skill row, Agent Tools -and the live Agent Context) and the `@tool` and `@viewer` templates. A bundle -that wants them passes it in `features`, as wfpy-ide's does. +The stock bundle binds no editors, sections or templates. wfpy-ide's own +bundle is an example of all three: its `src/webview/agent-ui/` holds the +`@tool` and `@agent` editors, the agent sections (Basic Info's Skill row, +Agent Tools, the live Agent Context) and the `@tool` and `@viewer` templates. **A diagram of your own.** When you compose the container yourself: diff --git a/packages/diagram-client/src/agent-ui/agent-editor.ts b/packages/diagram-client/src/agent-ui/agent-editor.ts deleted file mode 100644 index 816dbf0..0000000 --- a/packages/diagram-client/src/agent-ui/agent-editor.ts +++ /dev/null @@ -1,2076 +0,0 @@ -/** - * The `@agent` annotation's editor: prompt, agent preset and skill, MCP and LSP - * servers, output validators, and the model and backend controls. - */ -import { WorkflowDiagramMetadata } from '@dialogram/shared'; -import type { PropertyElement } from '../property-model'; -import { VscodeUi } from '../vscode-ui'; -import { clientBehavior } from '../profile'; -import type { AnnotationEditor, AnnotationEditorContext } from '../annotation-editor'; -import { - type WorkflowAgentSkill, - type WorkflowClaudeAgentProfile, - WorkflowShowAgentSkillsActionHandler, - WorkflowShowClaudeAgentsActionHandler, - WorkflowRequestAgentSkillsOperation, - WorkflowRequestClaudeAgentsOperation -} from '../editing-action-handlers'; -import { normalizeMultilineStringForEditor, serializeWfStringListExpr, stripQuotesIfStringLiteral, toWfStringLiteral, tryParseWfStringListExpr } from './literals'; - -export class AgentAnnotationEditor implements AnnotationEditor { - readonly annotationName = 'agent'; - readonly reservedArgs = [ - // Identity / model - 'prompt', 'claudeAgent', 'skill', 'model', 'provider', 'endpoint', - 'timeoutMs', 'contextBudget', 'stateful', - // Backend / runtime controls (dropdowns above) - 'truncationStrategy', 'fireableWithoutInput', 'transport', 'connector', 'mode', 'cliToolsMode', 'reasoningEffort', - // Sub-block toggles - 'useClaudeAgent', 'useSkill', 'usePrompt', 'useSkillHooks', - // MCP block - 'useMcp', 'mcpServers', 'mcpServerConfigs', 'mcpTransport', - // LSP block (+ shorthand scalars) - 'useLsp', 'lspServers', 'lspServerConfigs', 'lspCommand', 'lspArgs', - 'lspLanguageId', 'lspExtraFlags', 'lspPort', 'lspSeverityThreshold', 'lspMaxRepairAttempts', - // Output validators editor - 'outputValidators' - ]; - /** GUI-only display keys that are NOT valid decorator parameters. */ - readonly guiOnlyArgs = ['useMcp', 'useLsp', 'mcpServerConfigs', 'lspServerConfigs']; - readonly mergeArgUpdates = true; - readonly quickAdd = { - label: 'Add @agent', - title: 'Add or update @agent annotation', - annotationText: '@agent(\n prompt="Transform inputs to outputs",\n model="openai/gpt-5-mini"\n)' - }; - - private skillRootsMode: 'all' | 'project' = 'all'; - - /** The editor's arguments first, in a fixed order, then the rest; GUI-only keys never. */ - formatAnnotation(argsMap: ReadonlyMap, pairs: ReadonlyArray): string { - const guiOnlyKeys = new Set(this.guiOnlyArgs); - - const prompt = argsMap.get('prompt')?.trim(); - const model = argsMap.get('model')?.trim(); - const provider = argsMap.get('provider')?.trim(); - const endpoint = argsMap.get('endpoint')?.trim(); - const timeoutMs = argsMap.get('timeoutMs')?.trim(); - const contextBudget = argsMap.get('contextBudget')?.trim(); - const stateful = argsMap.get('stateful')?.trim(); - const claudeAgent = argsMap.get('claudeAgent')?.trim(); - const useClaudeAgent = argsMap.get('useClaudeAgent')?.trim(); - const skill = argsMap.get('skill')?.trim(); - const useSkill = argsMap.get('useSkill')?.trim(); - const usePrompt = argsMap.get('usePrompt')?.trim(); - const useSkillHooks = argsMap.get('useSkillHooks')?.trim(); - const truncationStrategy = argsMap.get('truncationStrategy')?.trim(); - const transport = argsMap.get('transport')?.trim(); - const connector = argsMap.get('connector')?.trim(); - const mode = argsMap.get('mode')?.trim(); - const cliToolsMode = argsMap.get('cliToolsMode')?.trim(); - const reasoningEffort = argsMap.get('reasoningEffort')?.trim(); - const fireableWithoutInput = argsMap.get('fireableWithoutInput')?.trim(); - - const ordered: Array<[string, string]> = []; - if (prompt) ordered.push(['prompt', prompt]); - if (claudeAgent) ordered.push(['claudeAgent', claudeAgent]); - if (skill) ordered.push(['skill', skill]); - if (model) ordered.push(['model', model]); - if (provider) ordered.push(['provider', provider]); - if (endpoint) ordered.push(['endpoint', endpoint]); - if (timeoutMs) ordered.push(['timeoutMs', timeoutMs]); - if (contextBudget) ordered.push(['contextBudget', contextBudget]); - if (stateful) ordered.push(['stateful', stateful]); - if (useClaudeAgent) ordered.push(['useClaudeAgent', useClaudeAgent]); - if (useSkill) ordered.push(['useSkill', useSkill]); - if (usePrompt) ordered.push(['usePrompt', usePrompt]); - if (useSkillHooks) ordered.push(['useSkillHooks', useSkillHooks]); - if (truncationStrategy) ordered.push(['truncationStrategy', truncationStrategy]); - if (transport) ordered.push(['transport', transport]); - if (connector) ordered.push(['connector', connector]); - if (mode) ordered.push(['mode', mode]); - if (cliToolsMode) ordered.push(['cliToolsMode', cliToolsMode]); - if (reasoningEffort) ordered.push(['reasoningEffort', reasoningEffort]); - if (fireableWithoutInput) ordered.push(['fireableWithoutInput', fireableWithoutInput]); - - for (const [k, v] of pairs) { - if (['prompt', 'claudeAgent', 'skill', 'model', 'provider', 'endpoint', 'timeoutMs', 'contextBudget', 'stateful', 'useClaudeAgent', 'useSkill', 'usePrompt', 'useSkillHooks', 'truncationStrategy', 'transport', 'connector', 'mode', 'cliToolsMode', 'reasoningEffort', 'fireableWithoutInput'].includes(k) || guiOnlyKeys.has(k)) continue; - ordered.push([k, v]); - } - - return ordered.length === 0 - ? '@agent' - : `@agent(\n${ordered.map(([k, v]) => ` ${k}=${v}`).join(',\n')}\n)`; - } - - render(context: AnnotationEditorContext): void { - const { elementId, args: argsMap, body, requestDiscovery: requestAgentDiscovery } = context; - - const knownSkills = WorkflowShowAgentSkillsActionHandler.getSkillsForElement(elementId); - const knownClaudeAgents = WorkflowShowClaudeAgentsActionHandler.getAgentsForElement(elementId); - if (requestAgentDiscovery) { - void context.dispatcher.dispatch( - WorkflowRequestAgentSkillsOperation.create({ - elementId, - rootsMode: this.skillRootsMode - }) - ); - } - void context.dispatcher.dispatch( - WorkflowRequestClaudeAgentsOperation.create({ - elementId, - }) - ); - - // Gather ports for IntelliSense - const node: PropertyElement | undefined = context.root?.index?.getById(elementId); - const children = node?.children || []; - const ports = children.filter((c) => c.type?.startsWith('port')); - const inputPorts = ports.filter((p) => p.args?.[WorkflowDiagramMetadata.IS_INPUT_PORT] === true).map((p) => (p.args?.[WorkflowDiagramMetadata.PORT_NAME] as string | undefined) || 'unnamed'); - const outputPorts = ports.filter((p) => p.args?.[WorkflowDiagramMetadata.IS_INPUT_PORT] === false).map((p) => (p.args?.[WorkflowDiagramMetadata.PORT_NAME] as string | undefined) || 'unnamed'); - - // Discovery mode is fixed to all roots for now. - this.skillRootsMode = 'all'; - - const createEnabledToggle = (initialChecked: boolean): { wrap: HTMLLabelElement; input: HTMLInputElement } => { - const wrap = document.createElement('label'); - wrap.className = 'agent-enabled-toggle'; - - const text = document.createElement('span'); - text.className = 'agent-enabled-text'; - text.textContent = 'Enabled'; - - const switchWrap = document.createElement('span'); - switchWrap.className = 'agent-enabled-switch'; - - const input = document.createElement('input'); - input.type = 'checkbox'; - input.className = 'agent-enabled-input'; - input.checked = initialChecked; - - const slider = document.createElement('span'); - slider.className = 'agent-enabled-slider'; - - const knob = document.createElement('span'); - knob.className = 'agent-enabled-knob'; - - slider.appendChild(knob); - switchWrap.appendChild(input); - switchWrap.appendChild(slider); - wrap.appendChild(text); - wrap.appendChild(switchWrap); - - return { wrap, input }; - }; - - // 0. Agent preset profile section - const claudeRow = document.createElement('div'); - claudeRow.className = 'agent-skill-block'; - const claudeHead = document.createElement('div'); - claudeHead.className = 'agent-section-head'; - const claudeLabel = document.createElement('div'); - claudeLabel.textContent = 'Agent Preset'; - claudeLabel.className = 'agent-skill-label'; - const claudeRaw = (argsMap.get('useClaudeAgent') ?? '').trim().toLowerCase(); - let claudeActive = claudeRaw ? claudeRaw === 'true' : false; - const claudeEnabledToggle = createEnabledToggle(claudeActive); - claudeHead.appendChild(claudeLabel); - claudeHead.appendChild(claudeEnabledToggle.wrap); - - const claudeContent = document.createElement('div'); - claudeContent.className = 'agent-section-content'; - - const claudeInput = document.createElement('input'); - claudeInput.className = 'annotation-input'; - claudeInput.placeholder = 'e.g. code-reviewer'; - claudeInput.value = stripQuotesIfStringLiteral(argsMap.get('claudeAgent') ?? ''); - claudeInput.addEventListener('input', () => { - const val = claudeInput.value.trim(); - if (val) argsMap.set('claudeAgent', toWfStringLiteral(val)); - else argsMap.delete('claudeAgent'); - }); - - const claudeHint = document.createElement('div'); - claudeHint.className = 'agent-skill-hint'; - claudeHint.textContent = 'Resolved from .claude/agents/.md'; - - const claudeActions = document.createElement('div'); - claudeActions.className = 'agent-skill-actions'; - - const pickClaudeBtn = document.createElement('button'); - pickClaudeBtn.type = 'button'; - pickClaudeBtn.className = 'mini-btn'; - pickClaudeBtn.textContent = 'Pick Preset'; - pickClaudeBtn.addEventListener('click', async () => { - const latest = WorkflowShowClaudeAgentsActionHandler.getAgentsForElement(elementId); - if (latest.length === 0) { - VscodeUi.instance.infoMessage('No agent presets found in discovery roots.'); - return; - } - const items = latest.map((a) => ({ - id: a.name, - label: a.name, - description: a.description || 'No description', - detail: a.path, - })); - const picked = await VscodeUi.instance.quickPick({ - items, - placeHolder: 'Select an agent preset' - }); - if (!picked) return; - claudeInput.value = picked; - argsMap.set('claudeAgent', toWfStringLiteral(picked)); - const latestMeta = latest.find((a) => a.name === picked); - updateClaudeStatus(latestMeta); - }); - - const openClaudeBtn = document.createElement('button'); - openClaudeBtn.type = 'button'; - openClaudeBtn.className = 'mini-btn'; - openClaudeBtn.textContent = 'Open Preset'; - openClaudeBtn.addEventListener('click', async () => { - const current = stripQuotesIfStringLiteral(argsMap.get('claudeAgent') ?? '').trim(); - if (!current) { - VscodeUi.instance.infoMessage('Set an agent preset name first.'); - return; - } - const latest = WorkflowShowClaudeAgentsActionHandler.getAgentsForElement(elementId); - const meta = latest.find((a) => a.name === current); - if (!meta) { - VscodeUi.instance.errorMessage(`Agent preset '${current}' was not found.`); - return; - } - const openResult = await VscodeUi.instance.executeCommand('vscode.open', [meta.path]); - if (openResult && typeof openResult === 'object' && 'error' in openResult) { - VscodeUi.instance.errorMessage(`Failed to open claude agent: ${String((openResult as any).error)}`); - } - }); - - const claudeStatus = document.createElement('div'); - claudeStatus.className = 'agent-skill-status'; - const claudeIgnoredRow = document.createElement('div'); - claudeIgnoredRow.className = 'agent-skill-chips'; - const claudeWarnings = document.createElement('div'); - claudeWarnings.className = 'agent-skill-warning'; - claudeWarnings.style.display = 'none'; - - const addClaudeChip = (label: string, tone: 'warn' | 'info'): void => { - const chip = document.createElement('span'); - chip.className = `agent-chip ${tone}`; - chip.textContent = tone === 'warn' ? `IGNORED ${label}` : `INFO ${label}`; - claudeIgnoredRow.appendChild(chip); - }; - - const renderClaudeWarnings = (meta: WorkflowClaudeAgentProfile | undefined): void => { - if (!meta || (meta.warnings?.length ?? 0) === 0) { - claudeWarnings.style.display = 'none'; - claudeWarnings.textContent = ''; - return; - } - claudeWarnings.style.display = 'block'; - claudeWarnings.textContent = `Warning: ${(meta.warnings ?? []).join(' | ')}`; - }; - - const renderClaudeIgnoredFields = (meta: WorkflowClaudeAgentProfile | undefined): void => { - claudeIgnoredRow.innerHTML = ''; - if (!meta) { - claudeIgnoredRow.style.display = 'none'; - return; - } - const ignored = meta.ignoredFields ?? []; - if (ignored.length === 0) { - claudeIgnoredRow.style.display = 'none'; - return; - } - claudeIgnoredRow.style.display = 'flex'; - for (const field of ignored) { - addClaudeChip(field, 'warn'); - } - }; - - const updateClaudeStatus = (meta: WorkflowClaudeAgentProfile | undefined): void => { - const current = stripQuotesIfStringLiteral(argsMap.get('claudeAgent') ?? '').trim(); - if (!current) { - claudeStatus.textContent = 'No agent preset selected.'; - renderClaudeIgnoredFields(undefined); - renderClaudeWarnings(undefined); - return; - } - if (!meta) { - claudeStatus.textContent = `Agent preset '${current}' not discovered.`; - renderClaudeIgnoredFields(undefined); - renderClaudeWarnings(undefined); - return; - } - claudeStatus.textContent = `${meta.description || 'No description.'}`; - renderClaudeIgnoredFields(meta); - renderClaudeWarnings(meta); - }; - - const syncClaudeActive = (): void => { - argsMap.set('useClaudeAgent', claudeActive ? 'true' : 'false'); - claudeEnabledToggle.input.checked = claudeActive; - claudeInput.disabled = !claudeActive; - pickClaudeBtn.disabled = !claudeActive; - openClaudeBtn.disabled = !claudeActive; - claudeContent.style.display = claudeActive ? '' : 'none'; - claudeRow.classList.toggle('disabled', !claudeActive); - if (!claudeActive) { - claudeStatus.textContent = 'Agent preset is deactivated.'; - claudeIgnoredRow.style.display = 'none'; - claudeWarnings.style.display = 'none'; - } else { - const selected = stripQuotesIfStringLiteral(argsMap.get('claudeAgent') ?? '').trim(); - const latest = WorkflowShowClaudeAgentsActionHandler.getAgentsForElement(elementId); - updateClaudeStatus(latest.find((a) => a.name === selected)); - } - }; - claudeEnabledToggle.input.addEventListener('change', () => { - claudeActive = claudeEnabledToggle.input.checked; - syncClaudeActive(); - }); - - claudeActions.appendChild(pickClaudeBtn); - claudeActions.appendChild(openClaudeBtn); - claudeRow.appendChild(claudeHead); - claudeContent.appendChild(claudeInput); - claudeContent.appendChild(claudeHint); - claudeContent.appendChild(claudeActions); - claudeContent.appendChild(claudeStatus); - claudeContent.appendChild(claudeIgnoredRow); - claudeContent.appendChild(claudeWarnings); - claudeRow.appendChild(claudeContent); - - const selectedClaudeName = stripQuotesIfStringLiteral(argsMap.get('claudeAgent') ?? '').trim(); - updateClaudeStatus(knownClaudeAgents.find((a) => a.name === selectedClaudeName)); - syncClaudeActive(); - - // 1. Skill section - const skillRow = document.createElement('div'); - skillRow.className = 'agent-skill-block'; - const skillHead = document.createElement('div'); - skillHead.className = 'agent-section-head'; - const skillLabel = document.createElement('div'); - skillLabel.textContent = 'Skill'; - skillLabel.className = 'agent-skill-label'; - const skillRaw = (argsMap.get('useSkill') ?? '').trim().toLowerCase(); - let skillActive = skillRaw ? skillRaw === 'true' : !!argsMap.get('skill'); - const skillEnabledToggle = createEnabledToggle(skillActive); - skillHead.appendChild(skillLabel); - skillHead.appendChild(skillEnabledToggle.wrap); - - const skillContent = document.createElement('div'); - skillContent.className = 'agent-section-content'; - const skillInput = document.createElement('input'); - skillInput.className = 'annotation-input'; - skillInput.placeholder = 'e.g. writer'; - skillInput.style.width = '100%'; - skillInput.style.boxSizing = 'border-box'; - skillInput.value = stripQuotesIfStringLiteral(argsMap.get('skill') ?? ''); - skillInput.addEventListener('input', () => { - const val = skillInput.value.trim(); - if (val) argsMap.set('skill', toWfStringLiteral(val)); - else argsMap.delete('skill'); - }); - - const skillHint = document.createElement('div'); - skillHint.className = 'agent-skill-hint'; - skillHint.textContent = 'Resolved at runtime from .wf/skills//SKILL.md'; - - skillRow.appendChild(skillHead); - skillRow.appendChild(skillInput); - skillRow.appendChild(skillHint); - - const skillActions = document.createElement('div'); - skillActions.className = 'agent-skill-actions'; - - const skillWarn = document.createElement('div'); - skillWarn.className = 'agent-skill-warning'; - skillWarn.style.display = 'none'; - - const renderSkillWarning = (meta: WorkflowAgentSkill | undefined): void => { - const warningText = meta ? skillWarningSummary(meta) : ''; - if (!warningText) { - skillWarn.style.display = 'none'; - skillWarn.textContent = ''; - return; - } - skillWarn.style.display = 'block'; - skillWarn.textContent = `Warning: ${warningText}`; - }; - - const chipRow = document.createElement('div'); - chipRow.className = 'agent-skill-chips'; - - const addChip = (label: string, tone: 'ok' | 'warn' | 'error' | 'info'): void => { - const chip = document.createElement('span'); - chip.className = `agent-chip ${tone}`; - if (tone === 'ok') { - chip.textContent = `OK ${label}`; - } else if (tone === 'warn') { - chip.textContent = `WARN ${label}`; - } else if (tone === 'error') { - chip.textContent = `ERR ${label}`; - } else { - chip.textContent = `INFO ${label}`; - } - chipRow.appendChild(chip); - }; - - const renderSkillChips = (meta: WorkflowAgentSkill | undefined): void => { - chipRow.innerHTML = ''; - const currentSkill = stripQuotesIfStringLiteral(argsMap.get('skill') ?? '').trim(); - if (!currentSkill) { - chipRow.style.display = 'none'; - return; - } - chipRow.style.display = 'flex'; - if (!meta) { - addChip('Skill not discovered', 'error'); - return; - } - if (!meta.hasSkillMd) { - addChip('SKILL.md missing', 'error'); - } - const missingCount = meta.missingDeclaredHooks?.length ?? 0; - if (missingCount > 0) { - addChip(`${missingCount} missing script${missingCount > 1 ? 's' : ''}`, 'warn'); - } - const warningCount = meta.warnings?.length ?? 0; - if (warningCount > 0) { - addChip(`${warningCount} parser warning${warningCount > 1 ? 's' : ''}`, 'warn'); - } - addChip(`${meta.hooks.length} script hook${meta.hooks.length !== 1 ? 's' : ''}`, 'info'); - if (meta.hasSkillMd && missingCount === 0 && warningCount === 0) { - addChip('Skill ready', 'ok'); - } - }; - - const updateSkillStatus = (meta: WorkflowAgentSkill | undefined): void => { - const currentSkill = stripQuotesIfStringLiteral(argsMap.get('skill') ?? '').trim(); - if (meta) { - skillStatus.textContent = describeSkillStatus(meta); - } else { - skillStatus.textContent = currentSkill - ? `Skill '${currentSkill}' not discovered yet.` - : 'No skill selected.'; - } - }; - - const pickSkillBtn = document.createElement('button'); - pickSkillBtn.type = 'button'; - pickSkillBtn.className = 'mini-btn'; - pickSkillBtn.textContent = 'Pick Skill'; - pickSkillBtn.addEventListener('click', async () => { - const latest = WorkflowShowAgentSkillsActionHandler.getSkillsForElement(elementId); - if (latest.length === 0) { - VscodeUi.instance.infoMessage('No discovered skills found in .wf/.claude roots.'); - return; - } - const items = latest.map((s) => ({ - id: s.name, - label: s.name, - description: (s.description && s.description.trim() !== '') - ? s.description - : (s.hasSkillMd ? 'No description in SKILL.md' : 'missing SKILL.md'), - detail: `${s.root}${(s.warnings?.length ?? 0) > 0 ? ` | ${s.warnings?.join(' | ')}` : ''}` - })); - const picked = await VscodeUi.instance.quickPick({ - items, - placeHolder: 'Select a skill' - }); - if (!picked) return; - skillInput.value = picked; - argsMap.set('skill', toWfStringLiteral(picked)); - const meta = latest.find((s) => s.name === picked); - updateSkillStatus(meta); - renderSkillChips(meta); - renderSkillWarning(meta); - }); - - const refreshSkillsBtn = document.createElement('button'); - refreshSkillsBtn.type = 'button'; - refreshSkillsBtn.className = 'mini-btn'; - refreshSkillsBtn.textContent = 'Refresh'; - refreshSkillsBtn.addEventListener('click', () => { - void context.dispatcher.dispatch( - WorkflowRequestAgentSkillsOperation.create({ - elementId, - rootsMode: this.skillRootsMode - }) - ); - const currentSkill = stripQuotesIfStringLiteral(argsMap.get('skill') ?? '').trim(); - const latest = WorkflowShowAgentSkillsActionHandler.getSkillsForElement(elementId); - const meta = latest.find((s) => s.name === currentSkill); - updateSkillStatus(meta); - renderSkillChips(meta); - renderSkillWarning(meta); - }); - - const openSkillBtn = document.createElement('button'); - openSkillBtn.type = 'button'; - openSkillBtn.className = 'mini-btn'; - openSkillBtn.textContent = 'Open Skill'; - openSkillBtn.addEventListener('click', async () => { - const currentSkill = stripQuotesIfStringLiteral(argsMap.get('skill') ?? '').trim(); - if (!currentSkill) { - VscodeUi.instance.infoMessage('Set a skill name first.'); - return; - } - const latest = WorkflowShowAgentSkillsActionHandler.getSkillsForElement(elementId); - let meta = latest.find((s) => s.name === currentSkill); - if (!meta) { - void context.dispatcher.dispatch( - WorkflowRequestAgentSkillsOperation.create({ - elementId, - rootsMode: this.skillRootsMode - }) - ); - const refreshed = WorkflowShowAgentSkillsActionHandler.getSkillsForElement(elementId); - meta = refreshed.find((s) => s.name === currentSkill); - } - if (!meta || !meta.skillMdPath) { - VscodeUi.instance.errorMessage(`Skill '${currentSkill}' was not found.`); - return; - } - // Open the rendered Markdown preview rather than the raw text editor. - const openResult = await VscodeUi.instance.executeCommand('markdown.showPreview', [meta.skillMdPath]); - if (openResult && typeof openResult === 'object' && 'error' in openResult) { - VscodeUi.instance.errorMessage(`Failed to open skill preview: ${String((openResult as any).error)}`); - } - }); - - skillActions.appendChild(pickSkillBtn); - skillActions.appendChild(refreshSkillsBtn); - skillActions.appendChild(openSkillBtn); - - const skillStatus = document.createElement('div'); - skillStatus.className = 'agent-skill-status'; - const selectedSkill = stripQuotesIfStringLiteral(argsMap.get('skill') ?? '').trim(); - const selectedMeta = knownSkills.find((s) => s.name === selectedSkill); - updateSkillStatus(selectedMeta); - renderSkillChips(selectedMeta); - renderSkillWarning(selectedMeta); - - const hooksWrap = document.createElement('label'); - hooksWrap.className = 'agent-toggle'; - const hooksInput = document.createElement('input'); - hooksInput.type = 'checkbox'; - const hooksRaw = (argsMap.get('useSkillHooks') ?? '').trim().toLowerCase(); - hooksInput.checked = hooksRaw ? hooksRaw === 'true' : true; - argsMap.set('useSkillHooks', hooksInput.checked ? 'true' : 'false'); - hooksInput.addEventListener('change', () => { - argsMap.set('useSkillHooks', hooksInput.checked ? 'true' : 'false'); - }); - const hooksText = document.createElement('span'); - hooksText.textContent = 'Enable Skill Hooks'; - hooksWrap.appendChild(hooksInput); - hooksWrap.appendChild(hooksText); - - const syncSkillActive = (): void => { - argsMap.set('useSkill', skillActive ? 'true' : 'false'); - skillEnabledToggle.input.checked = skillActive; - skillInput.disabled = !skillActive; - pickSkillBtn.disabled = !skillActive; - refreshSkillsBtn.disabled = !skillActive; - openSkillBtn.disabled = !skillActive; - hooksInput.disabled = !skillActive; - skillContent.style.display = skillActive ? '' : 'none'; - skillRow.classList.toggle('disabled', !skillActive); - if (!skillActive) { - chipRow.style.display = 'none'; - skillWarn.style.display = 'none'; - skillStatus.textContent = 'Skill is deactivated.'; - } else { - const currentSkill = stripQuotesIfStringLiteral(argsMap.get('skill') ?? '').trim(); - const latest = WorkflowShowAgentSkillsActionHandler.getSkillsForElement(elementId); - const meta = latest.find((s) => s.name === currentSkill); - updateSkillStatus(meta); - renderSkillChips(meta); - renderSkillWarning(meta); - } - }; - skillEnabledToggle.input.addEventListener('change', () => { - skillActive = skillEnabledToggle.input.checked; - syncSkillActive(); - }); - syncSkillActive(); - - skillContent.appendChild(skillInput); - skillContent.appendChild(skillHint); - skillContent.appendChild(skillActions); - skillContent.appendChild(hooksWrap); - skillContent.appendChild(skillStatus); - skillContent.appendChild(chipRow); - skillContent.appendChild(skillWarn); - skillRow.appendChild(skillContent); - - // 2. Prompt Section (Full Width) - const promptRow = document.createElement('div'); - promptRow.className = 'agent-skill-block agent-prompt-block'; - - const promptHeader = document.createElement('div'); - promptHeader.className = 'agent-prompt-header'; - - const promptLabel = document.createElement('div'); - promptLabel.innerHTML = 'Prompt '; - promptLabel.className = 'agent-prompt-label'; - - const promptRaw = (argsMap.get('usePrompt') ?? '').trim().toLowerCase(); - let promptActive = promptRaw ? promptRaw === 'true' : true; - const promptEnabledToggle = createEnabledToggle(promptActive); - - const popupBtn = document.createElement('button'); - popupBtn.className = 'mini-btn'; - popupBtn.innerHTML = ' Expand Editor'; - popupBtn.style.display = 'flex'; - popupBtn.style.gap = '4px'; - popupBtn.style.alignItems = 'center'; - - const promptHeaderRight = document.createElement('div'); - promptHeaderRight.style.display = 'flex'; - promptHeaderRight.style.gap = '8px'; - promptHeaderRight.appendChild(promptEnabledToggle.wrap); - - promptHeader.appendChild(promptLabel); - promptHeader.appendChild(promptHeaderRight); - promptRow.appendChild(promptHeader); - - const promptContent = document.createElement('div'); - promptContent.className = 'agent-section-content'; - - const promptActions = document.createElement('div'); - promptActions.className = 'agent-skill-actions'; - promptActions.appendChild(popupBtn); - promptContent.appendChild(promptActions); - - const promptInput = document.createElement('textarea'); - promptInput.className = 'annotation-input annotation-textarea'; - promptInput.rows = 6; - promptInput.placeholder = 'Transform inputs into outputs...\nWait for { to trigger autocomplete!'; - promptInput.classList.add('agent-prompt-input'); - - const updatePromptVal = (val: string) => { - promptInput.value = val; - argsMap.set('prompt', toWfStringLiteral(val)); - }; - - promptInput.value = normalizeMultilineStringForEditor( - stripQuotesIfStringLiteral(argsMap.get('prompt') ?? '') - ); - promptInput.addEventListener('input', () => { - argsMap.set('prompt', toWfStringLiteral(promptInput.value)); - }); - - const syncPromptActive = (): void => { - argsMap.set('usePrompt', promptActive ? 'true' : 'false'); - promptEnabledToggle.input.checked = promptActive; - promptInput.disabled = !promptActive; - popupBtn.disabled = !promptActive; - promptRow.classList.toggle('disabled', !promptActive); - promptContent.style.display = promptActive ? '' : 'none'; - }; - promptEnabledToggle.input.addEventListener('change', () => { - promptActive = promptEnabledToggle.input.checked; - syncPromptActive(); - }); - syncPromptActive(); - - const createAutocomplete = (inputEl: HTMLTextAreaElement, parent: HTMLElement, extraZIndex = 0) => { - const autocompleteBox = document.createElement('div'); - autocompleteBox.style.position = 'absolute'; - autocompleteBox.style.display = 'none'; - autocompleteBox.style.backgroundColor = 'var(--vscode-editorSuggestWidget-background, #252526)'; - autocompleteBox.style.border = '1px solid var(--vscode-editorSuggestWidget-border, #454545)'; - autocompleteBox.style.borderRadius = '4px'; - autocompleteBox.style.boxShadow = '0 4px 8px rgba(0,0,0,0.4)'; - autocompleteBox.style.zIndex = String(999999 + extraZIndex); - autocompleteBox.style.maxHeight = '150px'; - autocompleteBox.style.overflowY = 'auto'; - autocompleteBox.style.minWidth = '150px'; - autocompleteBox.style.fontSize = '12px'; - - parent.style.position = 'relative'; - parent.appendChild(autocompleteBox); - - inputEl.addEventListener('input', (e) => { - const val = inputEl.value; - const cursorPos = inputEl.selectionStart as number; - const textBefore = val.slice(0, cursorPos); - - const match = textBefore.match(/\{([a-zA-Z0-9_.]*)$/); - if (match) { - const searchStr = match[1].toLowerCase(); - - const suggestions = [ - ...inputPorts.map((p) => ({ display: `in.${p}`, val: `in.${p}`, kind: 'input' })), - ...outputPorts.map((p) => ({ display: `out.${p}`, val: `out.${p}`, kind: 'output' })) - ].filter(s => s.display.toLowerCase().includes(searchStr)); - - if (suggestions.length > 0) { - autocompleteBox.innerHTML = ''; - suggestions.forEach((s, idx) => { - const item = document.createElement('div'); - item.style.padding = '4px 8px'; - item.style.cursor = 'pointer'; - item.style.display = 'flex'; - item.style.justifyContent = 'space-between'; - item.style.borderBottom = idx < suggestions.length - 1 ? '1px solid var(--vscode-editorGroup-border)' : 'none'; - - const nameSpan = document.createElement('span'); - nameSpan.style.color = 'var(--vscode-symbolIcon-variableForeground)'; - nameSpan.style.fontWeight = 'bold'; - nameSpan.textContent = s.display; - - const kindSpan = document.createElement('span'); - kindSpan.style.opacity = '0.6'; - kindSpan.style.fontSize = '10px'; - kindSpan.textContent = s.kind; - - item.appendChild(nameSpan); - item.appendChild(kindSpan); - - item.onmouseover = () => item.style.backgroundColor = 'var(--vscode-editorSuggestWidget-selectedBackground, #062f4a)'; - item.onmouseout = () => item.style.backgroundColor = 'transparent'; - - item.onclick = () => { - const replaceStart = cursorPos - match[0].length; - const newVal = val.slice(0, replaceStart) + '{' + s.val + '}' + val.slice(cursorPos); - updatePromptVal(newVal); - autocompleteBox.style.display = 'none'; - inputEl.focus(); - argsMap.set('prompt', toWfStringLiteral(newVal)); - }; - autocompleteBox.appendChild(item); - }); - - autocompleteBox.style.display = 'block'; - autocompleteBox.style.marginTop = '4px'; - - } else { - autocompleteBox.style.display = 'none'; - } - } else { - autocompleteBox.style.display = 'none'; - } - }); - - document.addEventListener('click', (e) => { - if (e.target !== inputEl && !autocompleteBox.contains(e.target as Node)) { - autocompleteBox.style.display = 'none'; - } - }); - }; - - createAutocomplete(promptInput, promptContent); - - // Attach floating editor logic - popupBtn.addEventListener('click', () => { - const overlay = document.createElement('div'); - overlay.style.position = 'fixed'; - overlay.style.top = '0'; - overlay.style.left = '0'; - overlay.style.width = '100vw'; - overlay.style.height = '100vh'; - overlay.style.backgroundColor = 'rgba(0, 0, 0, 0.7)'; - overlay.style.backdropFilter = 'blur(4px)'; - overlay.style.zIndex = '9999999'; - overlay.style.display = 'flex'; - overlay.style.alignItems = 'center'; - overlay.style.justifyContent = 'center'; - overlay.style.transition = 'opacity 0.2s'; - - const modal = document.createElement('div'); - modal.style.width = '60vw'; - modal.style.minWidth = '500px'; - modal.style.height = '60vh'; - modal.style.backgroundColor = 'var(--vscode-editor-background, #1e1e1e)'; - modal.style.borderRadius = '8px'; - modal.style.boxShadow = '0 12px 32px rgba(0,0,0,0.5)'; - modal.style.border = '1px solid var(--vscode-widget-border, #454545)'; - modal.style.display = 'flex'; - modal.style.flexDirection = 'column'; - modal.style.overflow = 'hidden'; - - const header = document.createElement('div'); - header.style.padding = '12px 16px'; - header.style.backgroundColor = 'var(--vscode-editorGroupHeader-tabsBackground, #2d2d2d)'; - header.style.borderBottom = '1px solid var(--vscode-editorGroup-border, #454545)'; - header.style.display = 'flex'; - header.style.justifyContent = 'space-between'; - header.style.alignItems = 'center'; - - const title = document.createElement('div'); - title.innerHTML = ' Prompt Type { to use IntelliSense parameters'; - title.style.fontWeight = 'bold'; - title.style.color = 'var(--vscode-editor-foreground, #ccc)'; - - const closeBtn = document.createElement('button'); - closeBtn.innerHTML = ''; - closeBtn.className = 'mini-btn danger'; - closeBtn.style.border = 'none'; - closeBtn.style.cursor = 'pointer'; - closeBtn.onclick = () => document.body.removeChild(overlay); - - header.appendChild(title); - header.appendChild(closeBtn); - - const editorWrap = document.createElement('div'); - editorWrap.style.flex = '1'; - editorWrap.style.position = 'relative'; - editorWrap.style.padding = '16px'; - editorWrap.style.display = 'flex'; - editorWrap.style.flexDirection = 'column'; - - const largeInput = document.createElement('textarea'); - largeInput.style.flex = '1'; - largeInput.style.backgroundColor = 'transparent'; - largeInput.style.color = 'var(--vscode-editor-foreground, #ccc)'; - largeInput.style.border = 'none'; - largeInput.style.outline = 'none'; - largeInput.style.resize = 'none'; - largeInput.style.fontFamily = 'var(--vscode-editor-font-family, monospace)'; - largeInput.style.fontSize = '14px'; - largeInput.style.lineHeight = '1.5'; - largeInput.value = promptInput.value; - - largeInput.addEventListener('input', () => { - updatePromptVal(largeInput.value); - }); - - createAutocomplete(largeInput, editorWrap, 10000000); - - editorWrap.appendChild(largeInput); - - const footer = document.createElement('div'); - footer.style.padding = '12px 16px'; - footer.style.borderTop = '1px solid var(--vscode-editorGroup-border, #454545)'; - footer.style.display = 'flex'; - footer.style.justifyContent = 'space-between'; - footer.style.alignItems = 'center'; - - const footerHint = document.createElement('div'); - footerHint.textContent = 'Changes auto-saved. Click Apply & Save to confirm.'; - footerHint.style.fontSize = '11px'; - footerHint.style.opacity = '0.6'; - - const saveModalBtn = document.createElement('button'); - saveModalBtn.textContent = 'Apply & Close'; - saveModalBtn.className = 'mini-btn'; - saveModalBtn.style.padding = '4px 12px'; - saveModalBtn.style.fontSize = '12px'; - saveModalBtn.style.backgroundColor = 'var(--vscode-button-background, #0e639c)'; - saveModalBtn.style.color = 'var(--vscode-button-foreground, #fff)'; - saveModalBtn.onclick = () => { - context.save(); - document.body.removeChild(overlay); - }; - - footer.appendChild(footerHint); - footer.appendChild(saveModalBtn); - - modal.appendChild(header); - modal.appendChild(editorWrap); - modal.appendChild(footer); - overlay.appendChild(modal); - - document.body.appendChild(overlay); - largeInput.focus(); - }); - - promptContent.appendChild(promptInput); - promptRow.appendChild(promptContent); - body.appendChild(promptRow); - body.appendChild(claudeRow); - body.appendChild(skillRow); - - // 1b. MCP Servers section - const mcpRow = document.createElement('details'); - mcpRow.className = 'agent-skill-block agent-collapsible'; - const mcpHead = document.createElement('summary'); - mcpHead.className = 'agent-section-head'; - const mcpTitleWrap = document.createElement('span'); - mcpTitleWrap.className = 'agent-collapsible-title'; - const mcpChevron = document.createElement('span'); - mcpChevron.className = 'codicon codicon-chevron-right agent-collapse-chevron'; - const mcpLabel = document.createElement('div'); - mcpLabel.textContent = 'MCP'; - mcpLabel.className = 'agent-skill-label'; - mcpTitleWrap.appendChild(mcpChevron); - mcpTitleWrap.appendChild(mcpLabel); - const mcpRaw = (argsMap.get('useMcp') ?? '').trim().toLowerCase(); - let mcpActive = mcpRaw ? mcpRaw === 'true' : false; - const mcpEnabledToggle = createEnabledToggle(mcpActive); - // The Enabled toggle lives inside the ; a click on it must flip the - // switch, not collapse the section. Swallow the summary toggle and flip manually. - mcpEnabledToggle.wrap.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - mcpEnabledToggle.input.checked = !mcpEnabledToggle.input.checked; - mcpEnabledToggle.input.dispatchEvent(new Event('change')); - }); - mcpRow.open = false; // collapsed by default to cut noise; enabling expands it - mcpHead.appendChild(mcpTitleWrap); - mcpHead.appendChild(mcpEnabledToggle.wrap); - - const mcpContent = document.createElement('div'); - mcpContent.className = 'agent-section-content'; - - const mcpServersLabel = document.createElement('div'); - mcpServersLabel.textContent = 'Servers'; - mcpServersLabel.style.fontSize = '10px'; - mcpServersLabel.style.opacity = '0.8'; - mcpServersLabel.style.fontWeight = '500'; - mcpServersLabel.style.marginBottom = '4px'; - - const mcpServersInput = document.createElement('input'); - mcpServersInput.className = 'annotation-input'; - mcpServersInput.placeholder = 'e.g. filesystem, github'; - mcpServersInput.style.width = '100%'; - mcpServersInput.style.boxSizing = 'border-box'; - const mcpServersRaw = argsMap.get('mcpServers') ?? ''; - const mcpServersParsed = tryParseWfStringListExpr(mcpServersRaw); - mcpServersInput.value = mcpServersParsed ? mcpServersParsed.join(', ') : ''; - mcpServersInput.addEventListener('input', () => { - const val = mcpServersInput.value.trim(); - if (val) { - const items = val.split(',').map(s => s.trim()).filter(s => s.length > 0); - argsMap.set('mcpServers', serializeWfStringListExpr(items)); - } else { - argsMap.delete('mcpServers'); - } - }); - - const mcpHint = document.createElement('div'); - mcpHint.className = 'agent-skill-hint'; - mcpHint.textContent = 'Comma-separated MCP server names from agent-tools.json registry'; - - // Rich MCP server config (editable, from mcpServerConfigs JSON) - const mcpConfigsContainer = document.createElement('div'); - mcpConfigsContainer.style.marginTop = '8px'; - const mcpConfigsRaw = argsMap.get('mcpServerConfigs') ?? ''; - let mcpConfigsParsed: Array<{name: string; transport: string; url: string; command: string; args: string[]}> = []; - try { mcpConfigsParsed = mcpConfigsRaw ? JSON.parse(mcpConfigsRaw) : []; } catch { /* ignore parse errors */ } - - const syncMcpConfigs = (): void => { - if (mcpConfigsParsed.length > 0) { - argsMap.set('mcpServerConfigs', JSON.stringify(mcpConfigsParsed)); - } else { - argsMap.delete('mcpServerConfigs'); - } - }; - - const createMcpField = (labelText: string, value: string, onChange: (v: string) => void, placeholder = ''): HTMLElement => { - const row = document.createElement('div'); - row.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 4px;'; - const lbl = document.createElement('span'); - lbl.style.cssText = 'opacity: 0.7; min-width: 70px; font-size: 10px; font-weight: 500;'; - lbl.textContent = labelText; - const inp = document.createElement('input'); - inp.className = 'annotation-input'; - inp.style.cssText = 'flex: 1; box-sizing: border-box;'; - inp.value = value; - inp.placeholder = placeholder; - inp.addEventListener('input', () => onChange(inp.value)); - row.appendChild(lbl); - row.appendChild(inp); - return row; - }; - - let lastRemovedMcp: { cfg: {name: string; transport: string; url: string; command: string; args: string[]}; idx: number } | null = null; - const renderMcpCards = (): void => { - mcpConfigsContainer.innerHTML = ''; - if (lastRemovedMcp) { - const snap = lastRemovedMcp; - mcpConfigsContainer.appendChild(buildCardUndoBar( - 'MCP server', snap.cfg.name || '', - () => { mcpConfigsParsed.splice(Math.min(snap.idx, mcpConfigsParsed.length), 0, snap.cfg); lastRemovedMcp = null; syncMcpConfigs(); renderMcpCards(); }, - () => { lastRemovedMcp = null; renderMcpCards(); } - )); - } - mcpConfigsParsed.forEach((cfg, idx) => { - const card = document.createElement('div'); - card.style.cssText = 'background: var(--vscode-editor-background); border: 1px solid var(--vscode-panel-border, #333); border-radius: 4px; padding: 8px; margin-bottom: 6px; font-size: 11px;'; - const header = buildRemovableCardHeader('MCP server', cfg.name || 'Unnamed server', () => { lastRemovedMcp = { cfg, idx }; mcpConfigsParsed.splice(idx, 1); syncMcpConfigs(); renderMcpCards(); }); - card.appendChild(header); - - card.appendChild(createMcpField('Name', cfg.name || '', v => { cfg.name = v; const t = header.querySelector('.pp-card-title'); if (t) t.textContent = v.trim() || 'Unnamed server'; syncMcpConfigs(); }, 'e.g. my-mcp')); - // Transport select - const tRow = document.createElement('div'); - tRow.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 4px;'; - const tLbl = document.createElement('span'); - tLbl.style.cssText = 'opacity: 0.7; min-width: 70px; font-size: 10px; font-weight: 500;'; - tLbl.textContent = 'Transport'; - const tSel = document.createElement('select'); - tSel.className = 'annotation-input'; - tSel.style.cssText = 'flex: 1; box-sizing: border-box;'; - for (const opt of ['bridge', 'stdio', 'http', 'streamable-http']) { - const o = document.createElement('option'); - o.value = opt; o.textContent = opt; - tSel.appendChild(o); - } - tSel.value = cfg.transport || 'bridge'; - tSel.addEventListener('change', () => { cfg.transport = tSel.value; syncMcpConfigs(); }); - tRow.appendChild(tLbl); - tRow.appendChild(tSel); - card.appendChild(tRow); - - card.appendChild(createMcpField('URL', cfg.url || '', v => { cfg.url = v; syncMcpConfigs(); }, 'e.g. http://localhost:8080/mcp')); - card.appendChild(createMcpField('Command', cfg.command || '', v => { cfg.command = v; syncMcpConfigs(); }, 'e.g. npx')); - card.appendChild(createMcpField('Args', (cfg.args || []).join(' '), v => { cfg.args = v.trim() ? v.split(/\s+/) : []; syncMcpConfigs(); }, 'space-separated args')); - mcpConfigsContainer.appendChild(card); - }); - - // Add button - const addBtn = document.createElement('button'); - addBtn.textContent = '+ Add MCP Server'; - addBtn.style.cssText = 'background: none; border: 1px dashed var(--vscode-panel-border, #555); color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%;'; - addBtn.addEventListener('click', () => { - mcpConfigsParsed.push({name: '', transport: 'bridge', url: '', command: '', args: []}); - syncMcpConfigs(); - renderMcpCards(); - }); - mcpConfigsContainer.appendChild(addBtn); - }; - renderMcpCards(); - - const mcpTransportLabel = document.createElement('div'); - mcpTransportLabel.textContent = 'Transport'; - mcpTransportLabel.style.fontSize = '10px'; - mcpTransportLabel.style.opacity = '0.8'; - mcpTransportLabel.style.fontWeight = '500'; - mcpTransportLabel.style.marginTop = '8px'; - mcpTransportLabel.style.marginBottom = '4px'; - - const mcpTransportSelect = document.createElement('select'); - mcpTransportSelect.className = 'annotation-input'; - mcpTransportSelect.style.width = '100%'; - mcpTransportSelect.style.boxSizing = 'border-box'; - for (const opt of ['bridge', 'stdio', 'http', 'streamable-http']) { - const o = document.createElement('option'); - o.value = opt; - o.textContent = opt; - mcpTransportSelect.appendChild(o); - } - const mcpTransportRaw = stripQuotesIfStringLiteral(argsMap.get('mcpTransport') ?? '').trim(); - if (mcpTransportRaw && ['bridge', 'stdio', 'http', 'streamable-http'].includes(mcpTransportRaw)) { - mcpTransportSelect.value = mcpTransportRaw; - } - mcpTransportSelect.addEventListener('change', () => { - const val = mcpTransportSelect.value; - if (val && val !== 'bridge') { - argsMap.set('mcpTransport', toWfStringLiteral(val)); - } else { - argsMap.delete('mcpTransport'); - } - }); - - // Always show rich config container (has add button); hide basic transport when configs exist - const hasRichMcpConfigs = mcpConfigsParsed.length > 0; - - const syncMcpActive = (): void => { - argsMap.set('useMcp', mcpActive ? 'true' : 'false'); - mcpEnabledToggle.input.checked = mcpActive; - mcpServersInput.disabled = !mcpActive; - mcpTransportSelect.disabled = !mcpActive; - mcpRow.classList.toggle('disabled', !mcpActive); - }; - mcpEnabledToggle.input.addEventListener('change', () => { - mcpActive = mcpEnabledToggle.input.checked; - syncMcpActive(); - if (mcpActive) mcpRow.open = true; // reveal the config you just enabled - }); - syncMcpActive(); - - mcpContent.appendChild(mcpServersLabel); - mcpContent.appendChild(mcpServersInput); - mcpContent.appendChild(mcpHint); - mcpContent.appendChild(mcpConfigsContainer); - if (!hasRichMcpConfigs) { - mcpContent.appendChild(mcpTransportLabel); - mcpContent.appendChild(mcpTransportSelect); - } - mcpRow.appendChild(mcpHead); - mcpRow.appendChild(mcpContent); - body.appendChild(mcpRow); - - // 1c. LSP section - const lspRow = document.createElement('details'); - lspRow.className = 'agent-skill-block agent-collapsible'; - const lspHead = document.createElement('summary'); - lspHead.className = 'agent-section-head'; - const lspTitleWrap = document.createElement('span'); - lspTitleWrap.className = 'agent-collapsible-title'; - const lspChevron = document.createElement('span'); - lspChevron.className = 'codicon codicon-chevron-right agent-collapse-chevron'; - const lspLabel = document.createElement('div'); - lspLabel.textContent = 'LSP'; - lspLabel.className = 'agent-skill-label'; - lspTitleWrap.appendChild(lspChevron); - lspTitleWrap.appendChild(lspLabel); - const lspRaw = (argsMap.get('useLsp') ?? '').trim().toLowerCase(); - let lspActive = lspRaw ? lspRaw === 'true' : false; - const lspEnabledToggle = createEnabledToggle(lspActive); - // The Enabled toggle lives inside the ; a click on it must flip the - // switch, not collapse the section. Swallow the summary toggle and flip manually. - lspEnabledToggle.wrap.addEventListener('click', (e) => { - e.preventDefault(); - e.stopPropagation(); - lspEnabledToggle.input.checked = !lspEnabledToggle.input.checked; - lspEnabledToggle.input.dispatchEvent(new Event('change')); - }); - lspRow.open = false; // collapsed by default to cut noise; enabling expands it - lspHead.appendChild(lspTitleWrap); - lspHead.appendChild(lspEnabledToggle.wrap); - - const lspContent = document.createElement('div'); - lspContent.className = 'agent-section-content'; - - const lspServersLabel = document.createElement('div'); - lspServersLabel.textContent = 'Servers'; - lspServersLabel.style.fontSize = '10px'; - lspServersLabel.style.opacity = '0.8'; - lspServersLabel.style.fontWeight = '500'; - lspServersLabel.style.marginBottom = '4px'; - - const lspServersInput = document.createElement('input'); - lspServersInput.className = 'annotation-input'; - lspServersInput.placeholder = 'e.g. pylance, typescript'; - lspServersInput.style.width = '100%'; - lspServersInput.style.boxSizing = 'border-box'; - const lspServersRaw = argsMap.get('lspServers') ?? ''; - const lspServersParsed = tryParseWfStringListExpr(lspServersRaw); - lspServersInput.value = lspServersParsed ? lspServersParsed.join(', ') : ''; - lspServersInput.addEventListener('input', () => { - const val = lspServersInput.value.trim(); - if (val) { - const items = val.split(',').map(s => s.trim()).filter(s => s.length > 0); - argsMap.set('lspServers', serializeWfStringListExpr(items)); - } else { - argsMap.delete('lspServers'); - } - }); - - const lspHint = document.createElement('div'); - lspHint.className = 'agent-skill-hint'; - lspHint.textContent = 'Comma-separated LSP server names available to this agent'; - - // Rich LSP server config (editable, from lspServerConfigs JSON) - const lspConfigsContainer = document.createElement('div'); - lspConfigsContainer.style.marginTop = '8px'; - const lspConfigsRaw = argsMap.get('lspServerConfigs') ?? ''; - let lspConfigsParsed: Array<{ - name: string; command: string; args: string[]; - languageId: string; extraFlags: string[]; - ports: string[]; maxRepairAttempts: number; - severityThreshold: string; - }> = []; - try { lspConfigsParsed = lspConfigsRaw ? JSON.parse(lspConfigsRaw) : []; } catch { /* ignore */ } - - const syncLspConfigs = (): void => { - if (lspConfigsParsed.length > 0) { - argsMap.set('lspServerConfigs', JSON.stringify(lspConfigsParsed)); - } else { - argsMap.delete('lspServerConfigs'); - } - }; - - const createLspField = (labelText: string, value: string, onChange: (v: string) => void, placeholder = ''): HTMLElement => { - const row = document.createElement('div'); - row.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 4px;'; - const lbl = document.createElement('span'); - lbl.style.cssText = 'opacity: 0.7; min-width: 90px; font-size: 10px; font-weight: 500;'; - lbl.textContent = labelText; - const inp = document.createElement('input'); - inp.className = 'annotation-input'; - inp.style.cssText = 'flex: 1; box-sizing: border-box;'; - inp.value = value; - inp.placeholder = placeholder; - inp.addEventListener('input', () => onChange(inp.value)); - row.appendChild(lbl); - row.appendChild(inp); - return row; - }; - - let lastRemovedLsp: { cfg: typeof lspConfigsParsed[number]; idx: number } | null = null; - const renderLspCards = (): void => { - lspConfigsContainer.innerHTML = ''; - if (lastRemovedLsp) { - const snap = lastRemovedLsp; - lspConfigsContainer.appendChild(buildCardUndoBar( - 'LSP server', snap.cfg.command || snap.cfg.name || '', - () => { lspConfigsParsed.splice(Math.min(snap.idx, lspConfigsParsed.length), 0, snap.cfg); lastRemovedLsp = null; syncLspConfigs(); renderLspCards(); }, - () => { lastRemovedLsp = null; renderLspCards(); } - )); - } - lspConfigsParsed.forEach((cfg, idx) => { - const card = document.createElement('div'); - card.style.cssText = 'background: var(--vscode-editor-background); border: 1px solid var(--vscode-panel-border, #333); border-radius: 4px; padding: 8px; margin-bottom: 6px; font-size: 11px;'; - const header = buildRemovableCardHeader('LSP server', cfg.command || 'Unnamed server', () => { lastRemovedLsp = { cfg, idx }; lspConfigsParsed.splice(idx, 1); syncLspConfigs(); renderLspCards(); }); - card.appendChild(header); - - card.appendChild(createLspField('Command', cfg.command || '', v => { cfg.command = v; cfg.name = v; const t = header.querySelector('.pp-card-title'); if (t) t.textContent = v.trim() || 'Unnamed server'; syncLspConfigs(); }, 'e.g. clangd')); - card.appendChild(createLspField('Args', (cfg.args || []).join(' '), v => { cfg.args = v.trim() ? v.split(/\s+/) : []; syncLspConfigs(); }, 'space-separated')); - card.appendChild(createLspField('Language', cfg.languageId || '', v => { cfg.languageId = v; syncLspConfigs(); }, 'e.g. cpp')); - card.appendChild(createLspField('Extra Flags', (cfg.extraFlags || []).join(' '), v => { cfg.extraFlags = v.trim() ? v.split(/\s+/) : []; syncLspConfigs(); }, 'space-separated')); - card.appendChild(createLspField('Ports', (cfg.ports || []).join(', '), v => { cfg.ports = v.trim() ? v.split(',').map(s => s.trim()).filter(Boolean) : []; syncLspConfigs(); }, 'comma-separated')); - card.appendChild(createLspField('Max Repairs', String(cfg.maxRepairAttempts ?? 2), v => { cfg.maxRepairAttempts = parseInt(v, 10) || 2; syncLspConfigs(); }, '2')); - // Severity select - const sevRow = document.createElement('div'); - sevRow.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 4px;'; - const sevLbl = document.createElement('span'); - sevLbl.style.cssText = 'opacity: 0.7; min-width: 90px; font-size: 10px; font-weight: 500;'; - sevLbl.textContent = 'Severity'; - const sevSel = document.createElement('select'); - sevSel.className = 'annotation-input'; - sevSel.style.cssText = 'flex: 1; box-sizing: border-box;'; - for (const opt of ['error', 'warning', 'info', 'hint']) { - const o = document.createElement('option'); - o.value = opt; o.textContent = opt; - sevSel.appendChild(o); - } - sevSel.value = cfg.severityThreshold || 'error'; - sevSel.addEventListener('change', () => { cfg.severityThreshold = sevSel.value; syncLspConfigs(); }); - sevRow.appendChild(sevLbl); - sevRow.appendChild(sevSel); - card.appendChild(sevRow); - - lspConfigsContainer.appendChild(card); - }); - - // Add button - const addBtn = document.createElement('button'); - addBtn.textContent = '+ Add LSP Server'; - addBtn.style.cssText = 'background: none; border: 1px dashed var(--vscode-panel-border, #555); color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%;'; - addBtn.addEventListener('click', () => { - lspConfigsParsed.push({name: '', command: '', args: [], languageId: '', extraFlags: [], ports: [], maxRepairAttempts: 2, severityThreshold: 'error'}); - syncLspConfigs(); - renderLspCards(); - }); - lspConfigsContainer.appendChild(addBtn); - }; - renderLspCards(); - - const hasRichLspConfigs = lspConfigsParsed.length > 0; - - const lspCommandLabel = document.createElement('div'); - lspCommandLabel.textContent = 'Command'; - lspCommandLabel.style.fontSize = '10px'; - lspCommandLabel.style.opacity = '0.8'; - lspCommandLabel.style.fontWeight = '500'; - lspCommandLabel.style.marginTop = '8px'; - lspCommandLabel.style.marginBottom = '4px'; - - const lspCommandInput = document.createElement('input'); - lspCommandInput.className = 'annotation-input'; - lspCommandInput.placeholder = 'e.g. pylsp'; - lspCommandInput.style.width = '100%'; - lspCommandInput.style.boxSizing = 'border-box'; - lspCommandInput.value = stripQuotesIfStringLiteral(argsMap.get('lspCommand') ?? ''); - lspCommandInput.addEventListener('input', () => { - const val = lspCommandInput.value.trim(); - if (val) argsMap.set('lspCommand', toWfStringLiteral(val)); - else argsMap.delete('lspCommand'); - }); - - const lspPortLabel = document.createElement('div'); - lspPortLabel.textContent = 'Port'; - lspPortLabel.style.fontSize = '10px'; - lspPortLabel.style.opacity = '0.8'; - lspPortLabel.style.fontWeight = '500'; - lspPortLabel.style.marginTop = '8px'; - lspPortLabel.style.marginBottom = '4px'; - - const lspPortInput = document.createElement('input'); - lspPortInput.type = 'number'; - lspPortInput.className = 'annotation-input'; - lspPortInput.placeholder = 'e.g. 2087'; - lspPortInput.style.width = '100%'; - lspPortInput.style.boxSizing = 'border-box'; - lspPortInput.value = argsMap.get('lspPort') ?? ''; - lspPortInput.addEventListener('input', () => { - const val = lspPortInput.value.trim(); - if (val) argsMap.set('lspPort', val); - else argsMap.delete('lspPort'); - }); - - // Shorthand scalars accompanying the single lsp_command form. (The rich - // lspServerConfigs editor covers these per-server; these apply when you use - // the one-command shorthand instead.) - const lspShortTextField = (labelText: string, key: string, placeholder: string, isList: boolean): HTMLElement => { - const wrap = document.createElement('div'); - const lbl = document.createElement('div'); - lbl.textContent = labelText; - lbl.style.cssText = 'font-size:10px;opacity:0.8;font-weight:500;margin-top:8px;margin-bottom:4px;'; - const inp = document.createElement('input'); - inp.className = 'annotation-input'; - inp.placeholder = placeholder; - inp.style.cssText = 'width:100%;box-sizing:border-box;'; - if (isList) { - const parsed = tryParseWfStringListExpr(argsMap.get(key) ?? ''); - inp.value = parsed ? parsed.join(' ') : ''; - inp.addEventListener('input', () => { - const v = inp.value.trim(); - if (v) argsMap.set(key, serializeWfStringListExpr(v.split(/\s+/))); - else argsMap.delete(key); - }); - } else { - inp.value = stripQuotesIfStringLiteral(argsMap.get(key) ?? ''); - inp.addEventListener('input', () => { - const v = inp.value.trim(); - if (v) argsMap.set(key, toWfStringLiteral(v)); - else argsMap.delete(key); - }); - } - wrap.appendChild(lbl); - wrap.appendChild(inp); - return wrap; - }; - const lspArgsField = lspShortTextField('Args', 'lspArgs', 'space-separated', true); - const lspLanguageField = lspShortTextField('Language', 'lspLanguageId', 'e.g. cpp', false); - const lspExtraFlagsField = lspShortTextField('Extra Flags', 'lspExtraFlags', 'space-separated', true); - - const lspSevWrap = document.createElement('div'); - const lspSevLabel = document.createElement('div'); - lspSevLabel.textContent = 'Severity'; - lspSevLabel.style.cssText = 'font-size:10px;opacity:0.8;font-weight:500;margin-top:8px;margin-bottom:4px;'; - const lspSevSelect = document.createElement('select'); - lspSevSelect.className = 'annotation-input'; - lspSevSelect.style.cssText = 'width:100%;box-sizing:border-box;'; - for (const opt of ['error', 'warning', 'information', 'hint']) { - const o = document.createElement('option'); o.value = opt; o.textContent = opt; lspSevSelect.appendChild(o); - } - const lspSevCurrent = stripQuotesIfStringLiteral(argsMap.get('lspSeverityThreshold') ?? '').trim(); - lspSevSelect.value = ['error', 'warning', 'information', 'hint'].includes(lspSevCurrent) ? lspSevCurrent : 'error'; - lspSevSelect.addEventListener('change', () => { - if (lspSevSelect.value && lspSevSelect.value !== 'error') argsMap.set('lspSeverityThreshold', toWfStringLiteral(lspSevSelect.value)); - else argsMap.delete('lspSeverityThreshold'); - }); - lspSevWrap.appendChild(lspSevLabel); - lspSevWrap.appendChild(lspSevSelect); - - const lspMaxWrap = document.createElement('div'); - const lspMaxLabel = document.createElement('div'); - lspMaxLabel.textContent = 'Max Repairs'; - lspMaxLabel.style.cssText = 'font-size:10px;opacity:0.8;font-weight:500;margin-top:8px;margin-bottom:4px;'; - const lspMaxInput = document.createElement('input'); - lspMaxInput.type = 'number'; - lspMaxInput.min = '0'; - lspMaxInput.className = 'annotation-input'; - lspMaxInput.placeholder = '2'; - lspMaxInput.style.cssText = 'width:100%;box-sizing:border-box;'; - lspMaxInput.value = argsMap.get('lspMaxRepairAttempts') ?? ''; - lspMaxInput.addEventListener('input', () => { - const v = lspMaxInput.value.trim(); - if (v && v !== '2') argsMap.set('lspMaxRepairAttempts', v); - else argsMap.delete('lspMaxRepairAttempts'); - }); - lspMaxWrap.appendChild(lspMaxLabel); - lspMaxWrap.appendChild(lspMaxInput); - - const syncLspActive = (): void => { - argsMap.set('useLsp', lspActive ? 'true' : 'false'); - lspEnabledToggle.input.checked = lspActive; - lspServersInput.disabled = !lspActive; - lspCommandInput.disabled = !lspActive; - lspPortInput.disabled = !lspActive; - lspRow.classList.toggle('disabled', !lspActive); - }; - lspEnabledToggle.input.addEventListener('change', () => { - lspActive = lspEnabledToggle.input.checked; - syncLspActive(); - if (lspActive) lspRow.open = true; // reveal the config you just enabled - }); - syncLspActive(); - - lspContent.appendChild(lspServersLabel); - lspContent.appendChild(lspServersInput); - lspContent.appendChild(lspHint); - lspContent.appendChild(lspConfigsContainer); - if (!hasRichLspConfigs) { - lspContent.appendChild(lspCommandLabel); - lspContent.appendChild(lspCommandInput); - lspContent.appendChild(lspArgsField); - lspContent.appendChild(lspLanguageField); - lspContent.appendChild(lspExtraFlagsField); - lspContent.appendChild(lspSevWrap); - lspContent.appendChild(lspMaxWrap); - lspContent.appendChild(lspPortLabel); - lspContent.appendChild(lspPortInput); - } - lspRow.appendChild(lspHead); - lspRow.appendChild(lspContent); - body.appendChild(lspRow); - - // 1d. Output Validators — per-agent output checks (cmd: run any tool - // against the output file; lsp: structured diagnostics). Unlike MCP/LSP - // configs these fully round-trip to source (managed arg, not GUI-only). - // Collapsed by default (native
) — validators are an advanced, - // usually-empty section, so keep it out of the way until needed. The summary - // carries a count badge so the collapsed state still shows how many exist. - const ovRow = document.createElement('details'); - ovRow.className = 'agent-skill-block agent-collapsible'; - const ovHead = document.createElement('summary'); - ovHead.className = 'agent-section-head'; - const ovLabelWrap = document.createElement('span'); - ovLabelWrap.className = 'agent-collapsible-title'; - const ovChevron = document.createElement('span'); - ovChevron.className = 'codicon codicon-chevron-right agent-collapse-chevron'; - const ovLabel = document.createElement('span'); - ovLabel.textContent = 'Output Validators'; - ovLabel.className = 'agent-skill-label'; - ovLabelWrap.appendChild(ovChevron); - ovLabelWrap.appendChild(ovLabel); - const ovCount = document.createElement('span'); - ovCount.className = 'agent-collapsible-count'; - ovHead.appendChild(ovLabelWrap); - ovHead.appendChild(ovCount); - - const ovContent = document.createElement('div'); - ovContent.className = 'agent-section-content'; - const ovHint = document.createElement('div'); - ovHint.className = 'agent-skill-hint'; - ovHint.textContent = 'Validate agent output by running a command or an LSP server; failures re-prompt the agent.'; - ovContent.appendChild(ovHint); - - type OvConfig = { - kind: string; cmd: string; args: string[]; ports: string[]; - maxRepairAttempts: number; severityThreshold: string; - languageId: string; extraFlags: string[]; - }; - let ovParsed: OvConfig[] = []; - try { - const raw = argsMap.get('outputValidators') ?? ''; - ovParsed = raw ? JSON.parse(raw) : []; - } catch { /* ignore malformed */ } - - const syncOvConfigs = (): void => { - if (ovParsed.length === 0) { - argsMap.delete('outputValidators'); - return; - } - // Emit only non-empty fields, and only string/int/array values, so the - // JSON is a valid literal the decorator can re-parse. - const clean = ovParsed.map((v) => { - const c: Record = { - kind: v.kind || 'cmd', - cmd: v.cmd || '', - args: v.args || [], - maxRepairAttempts: v.maxRepairAttempts ?? 2, - severityThreshold: v.severityThreshold || 'error' - }; - if (v.ports && v.ports.length) c.ports = v.ports; - if (v.kind === 'lsp') { - if (v.languageId) c.languageId = v.languageId; - if (v.extraFlags && v.extraFlags.length) c.extraFlags = v.extraFlags; - } - return c; - }); - argsMap.set('outputValidators', JSON.stringify(clean)); - }; - - const ovCardsContainer = document.createElement('div'); - ovCardsContainer.style.marginTop = '4px'; - - let lastRemovedOv: { cfg: OvConfig; idx: number } | null = null; - const ovTitle = (cfg: OvConfig): string => (cfg.cmd && cfg.cmd.trim()) || `${cfg.kind || 'cmd'} validator`; - const renderOvCards = (): void => { - ovCount.textContent = ovParsed.length ? String(ovParsed.length) : ''; - ovCardsContainer.innerHTML = ''; - if (lastRemovedOv) { - const snap = lastRemovedOv; - ovCardsContainer.appendChild(buildCardUndoBar( - 'validator', ovTitle(snap.cfg), - () => { ovParsed.splice(Math.min(snap.idx, ovParsed.length), 0, snap.cfg); lastRemovedOv = null; syncOvConfigs(); renderOvCards(); }, - () => { lastRemovedOv = null; renderOvCards(); } - )); - } - ovParsed.forEach((cfg, idx) => { - const card = document.createElement('div'); - card.style.cssText = 'background: var(--vscode-editor-background); border: 1px solid var(--vscode-panel-border, #333); border-radius: 4px; padding: 8px; margin-bottom: 6px; font-size: 11px;'; - card.appendChild(buildRemovableCardHeader('validator', ovTitle(cfg), () => { lastRemovedOv = { cfg, idx }; ovParsed.splice(idx, 1); syncOvConfigs(); renderOvCards(); })); - - // Kind select (cmd | lsp) - const kindRow = document.createElement('div'); - kindRow.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 4px;'; - const kindLbl = document.createElement('span'); - kindLbl.style.cssText = 'opacity: 0.7; min-width: 90px; font-size: 10px; font-weight: 500;'; - kindLbl.textContent = 'Kind'; - const kindSel = document.createElement('select'); - kindSel.className = 'annotation-input'; - kindSel.style.cssText = 'flex: 1; box-sizing: border-box;'; - for (const opt of ['cmd', 'lsp']) { - const o = document.createElement('option'); - o.value = opt; o.textContent = opt; - kindSel.appendChild(o); - } - kindSel.value = cfg.kind || 'cmd'; - kindSel.addEventListener('change', () => { cfg.kind = kindSel.value; syncOvConfigs(); renderOvCards(); }); - kindRow.appendChild(kindLbl); - kindRow.appendChild(kindSel); - card.appendChild(kindRow); - - const ovField = (labelText: string, value: string, onChange: (v: string) => void, placeholder = ''): HTMLElement => { - const row = document.createElement('div'); - row.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 4px;'; - const lbl = document.createElement('span'); - lbl.style.cssText = 'opacity: 0.7; min-width: 90px; font-size: 10px; font-weight: 500;'; - lbl.textContent = labelText; - const inp = document.createElement('input'); - inp.className = 'annotation-input'; - inp.style.cssText = 'flex: 1; box-sizing: border-box;'; - inp.value = value; - inp.placeholder = placeholder; - inp.addEventListener('input', () => onChange(inp.value)); - row.appendChild(lbl); - row.appendChild(inp); - return row; - }; - - card.appendChild(ovField('Command', cfg.cmd || '', v => { cfg.cmd = v; syncOvConfigs(); }, cfg.kind === 'lsp' ? 'e.g. clangd' : 'e.g. bisheng')); - card.appendChild(ovField('Args', (cfg.args || []).join(' '), v => { cfg.args = v.trim() ? v.split(/\s+/) : []; syncOvConfigs(); }, '{file} placeholder allowed')); - card.appendChild(ovField('Ports', (cfg.ports || []).join(', '), v => { cfg.ports = v.trim() ? v.split(',').map(s => s.trim()).filter(Boolean) : []; syncOvConfigs(); }, 'comma-separated (empty = all File ports)')); - card.appendChild(ovField('Max Repairs', String(cfg.maxRepairAttempts ?? 2), v => { cfg.maxRepairAttempts = parseInt(v, 10) || 2; syncOvConfigs(); }, '2')); - - // Severity select - const sevRow = document.createElement('div'); - sevRow.style.cssText = 'display: flex; align-items: center; gap: 6px; margin-bottom: 4px;'; - const sevLbl = document.createElement('span'); - sevLbl.style.cssText = 'opacity: 0.7; min-width: 90px; font-size: 10px; font-weight: 500;'; - sevLbl.textContent = 'Severity'; - const sevSel = document.createElement('select'); - sevSel.className = 'annotation-input'; - sevSel.style.cssText = 'flex: 1; box-sizing: border-box;'; - for (const opt of ['error', 'warning', 'information', 'hint']) { - const o = document.createElement('option'); - o.value = opt; o.textContent = opt; - sevSel.appendChild(o); - } - sevSel.value = cfg.severityThreshold || 'error'; - sevSel.addEventListener('change', () => { cfg.severityThreshold = sevSel.value; syncOvConfigs(); }); - sevRow.appendChild(sevLbl); - sevRow.appendChild(sevSel); - card.appendChild(sevRow); - - // LSP-only fields - if (cfg.kind === 'lsp') { - card.appendChild(ovField('Language', cfg.languageId || '', v => { cfg.languageId = v; syncOvConfigs(); }, 'e.g. cpp')); - card.appendChild(ovField('Extra Flags', (cfg.extraFlags || []).join(' '), v => { cfg.extraFlags = v.trim() ? v.split(/\s+/) : []; syncOvConfigs(); }, 'space-separated')); - } - - ovCardsContainer.appendChild(card); - }); - - const addBtn = document.createElement('button'); - addBtn.textContent = '+ Add Output Validator'; - addBtn.style.cssText = 'background: none; border: 1px dashed var(--vscode-panel-border, #555); color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%;'; - addBtn.addEventListener('click', () => { - ovParsed.push({ kind: 'cmd', cmd: '', args: [], ports: [], maxRepairAttempts: 2, severityThreshold: 'error', languageId: '', extraFlags: [] }); - syncOvConfigs(); - renderOvCards(); - }); - ovCardsContainer.appendChild(addBtn); - }; - renderOvCards(); - - ovContent.appendChild(ovCardsContainer); - ovRow.appendChild(ovHead); - ovRow.appendChild(ovContent); - body.appendChild(ovRow); - - // 3. Model Configuration (2-column layout) - const modelGrid = document.createElement('div'); - modelGrid.style.display = 'grid'; - modelGrid.style.gridTemplateColumns = '1fr 1fr'; - modelGrid.style.gap = '8px'; - modelGrid.style.marginBottom = '8px'; - - const createField = (key: string, label: string, placeholder: string) => { - const wrapper = document.createElement('div'); - wrapper.style.display = 'flex'; - wrapper.style.flexDirection = 'column'; - wrapper.style.gap = '4px'; - - const l = document.createElement('div'); - l.textContent = label; - l.style.fontSize = '10px'; - l.style.opacity = '0.8'; - l.style.fontWeight = '500'; - - const inp = document.createElement('input'); - inp.className = 'annotation-input'; - inp.placeholder = placeholder; - inp.style.width = '100%'; - inp.style.boxSizing = 'border-box'; - inp.value = stripQuotesIfStringLiteral(argsMap.get(key) ?? ''); - inp.addEventListener('input', () => { - const val = inp.value.trim(); - if (val) argsMap.set(key, toWfStringLiteral(val)); - else argsMap.delete(key); - }); - - wrapper.appendChild(l); - wrapper.appendChild(inp); - return wrapper; - }; - - // Dropdown for enum-valued @agent args. `defaultValue` is the value - // that means "unset" (omitted from source); selecting it deletes the arg. - const createSelect = ( - key: string, - label: string, - options: Array<{ value: string; label: string; group?: string }>, - defaultValue: string, - onChange?: (value: string) => void - ) => { - const wrapper = document.createElement('div'); - wrapper.style.display = 'flex'; - wrapper.style.flexDirection = 'column'; - wrapper.style.gap = '4px'; - - const l = document.createElement('div'); - l.textContent = label; - l.style.fontSize = '10px'; - l.style.opacity = '0.8'; - l.style.fontWeight = '500'; - - const sel = document.createElement('select'); - sel.className = 'annotation-input'; - sel.style.width = '100%'; - sel.style.boxSizing = 'border-box'; - // Render s when options declare a group, so distinct - // categories (e.g. API vs CLI vs ACP transport) read as distinct. - const optGroups = new Map(); - for (const opt of options) { - const o = document.createElement('option'); - o.value = opt.value; - o.textContent = opt.label; - if (opt.group) { - let og = optGroups.get(opt.group); - if (!og) { - og = document.createElement('optgroup'); - og.label = opt.group; - optGroups.set(opt.group, og); - sel.appendChild(og); - } - og.appendChild(o); - } else { - sel.appendChild(o); - } - } - const current = stripQuotesIfStringLiteral(argsMap.get(key) ?? '').trim(); - sel.value = options.some((o) => o.value === current) ? current : defaultValue; - sel.addEventListener('change', () => { - const val = sel.value; - if (val && val !== defaultValue) { - argsMap.set(key, toWfStringLiteral(val)); - } else { - argsMap.delete(key); - } - onChange?.(val); - }); - - wrapper.appendChild(l); - wrapper.appendChild(sel); - return wrapper; - }; - - modelGrid.appendChild(createField('model', 'Model', 'e.g. gpt-4')); - modelGrid.appendChild(createField('provider', 'Provider', 'e.g. openai')); - body.appendChild(modelGrid); - - // 4. Endpoint (Full Width) - const epRow = document.createElement('div'); - epRow.style.marginBottom = '12px'; - - const epLabel = document.createElement('div'); - epLabel.textContent = 'Endpoint'; - epLabel.style.fontSize = '10px'; - epLabel.style.opacity = '0.8'; - epLabel.style.fontWeight = '500'; - epLabel.style.marginBottom = '4px'; - - const epInput = document.createElement('input'); - epInput.className = 'annotation-input'; - epInput.placeholder = 'https://api.openai.com/v1'; - epInput.style.width = '100%'; - epInput.style.boxSizing = 'border-box'; - epInput.value = stripQuotesIfStringLiteral(argsMap.get('endpoint') ?? ''); - epInput.addEventListener('input', () => { - const val = epInput.value.trim(); - if (val) argsMap.set('endpoint', toWfStringLiteral(val)); - else argsMap.delete('endpoint'); - }); - - epRow.appendChild(epLabel); - epRow.appendChild(epInput); - body.appendChild(epRow); - - // 5. Execution Settings (Grid: Timeout, Budget, Stateful) - const execGrid = document.createElement('div'); - execGrid.style.display = 'grid'; - execGrid.style.gridTemplateColumns = '1fr 1fr auto'; - execGrid.style.gap = '8px'; - execGrid.style.alignItems = 'end'; // Align checkbox with inputs - execGrid.style.paddingTop = '4px'; - execGrid.style.borderTop = '1px solid var(--vscode-editorWidget-border)'; - - // Timeout - const timeoutWrapper = document.createElement('div'); - timeoutWrapper.style.display = 'flex'; - timeoutWrapper.style.flexDirection = 'column'; - timeoutWrapper.style.gap = '4px'; - const timeoutLabel = document.createElement('div'); - timeoutLabel.textContent = 'Timeout (ms)'; - timeoutLabel.style.fontSize = '10px'; - timeoutLabel.style.opacity = '0.8'; - const timeoutInput = document.createElement('input'); - timeoutInput.type = 'number'; - timeoutInput.className = 'annotation-input'; - timeoutInput.placeholder = '30000'; - timeoutInput.style.width = '100%'; - timeoutInput.style.boxSizing = 'border-box'; - timeoutInput.value = argsMap.get('timeoutMs') ?? ''; - timeoutInput.addEventListener('input', () => { - const val = timeoutInput.value.trim(); - if (val) argsMap.set('timeoutMs', val); - else argsMap.delete('timeoutMs'); - }); - timeoutWrapper.appendChild(timeoutLabel); - timeoutWrapper.appendChild(timeoutInput); - execGrid.appendChild(timeoutWrapper); - - // Budget - const budgetWrapper = document.createElement('div'); - budgetWrapper.style.display = 'flex'; - budgetWrapper.style.flexDirection = 'column'; - budgetWrapper.style.gap = '4px'; - const budgetLabel = document.createElement('div'); - budgetLabel.textContent = 'Context Budget'; - budgetLabel.style.fontSize = '10px'; - budgetLabel.style.opacity = '0.8'; - const budgetInput = document.createElement('input'); - budgetInput.type = 'number'; - budgetInput.className = 'annotation-input'; - budgetInput.placeholder = '20'; - budgetInput.style.width = '100%'; - budgetInput.style.boxSizing = 'border-box'; - budgetInput.value = argsMap.get('contextBudget') ?? ''; - budgetInput.addEventListener('input', () => { - const val = budgetInput.value.trim(); - if (val) argsMap.set('contextBudget', val); - else argsMap.delete('contextBudget'); - }); - budgetWrapper.appendChild(budgetLabel); - budgetWrapper.appendChild(budgetInput); - execGrid.appendChild(budgetWrapper); - - // Stateful Toggle - const statefulWrapper = document.createElement('div'); - statefulWrapper.style.display = 'flex'; - statefulWrapper.style.flexDirection = 'column'; - statefulWrapper.style.gap = '4px'; - statefulWrapper.style.alignItems = 'center'; - - const statefulLabel = document.createElement('div'); - statefulLabel.textContent = 'Stateful'; - statefulLabel.style.fontSize = '10px'; - statefulLabel.style.opacity = '0.8'; - - const toggleLabel = document.createElement('label'); - toggleLabel.style.display = 'inline-block'; - toggleLabel.style.position = 'relative'; - toggleLabel.style.width = '32px'; - toggleLabel.style.height = '18px'; - toggleLabel.style.marginBottom = '3px'; // align with input height - - const toggleInput = document.createElement('input'); - toggleInput.type = 'checkbox'; - toggleInput.style.opacity = '0'; - toggleInput.style.width = '0'; - toggleInput.style.height = '0'; - toggleInput.checked = (argsMap.get('stateful') ?? '').trim() === 'true'; - - const toggleSlider = document.createElement('span'); - toggleSlider.style.position = 'absolute'; - toggleSlider.style.cursor = 'pointer'; - toggleSlider.style.top = '0'; - toggleSlider.style.left = '0'; - toggleSlider.style.right = '0'; - toggleSlider.style.bottom = '0'; - toggleSlider.style.backgroundColor = 'var(--vscode-input-background)'; - toggleSlider.style.border = '1px solid var(--vscode-input-border)'; - toggleSlider.style.transition = '.2s'; - toggleSlider.style.borderRadius = '18px'; - - const toggleKnob = document.createElement('span'); - toggleKnob.style.position = 'absolute'; - toggleKnob.style.content = '""'; - toggleKnob.style.height = '12px'; - toggleKnob.style.width = '12px'; - toggleKnob.style.left = '3px'; - toggleKnob.style.bottom = '2px'; - toggleKnob.style.backgroundColor = 'var(--vscode-foreground)'; - toggleKnob.style.transition = '.2s'; - toggleKnob.style.borderRadius = '50%'; - toggleKnob.style.opacity = '0.6'; - - // Toggle Logic - const updateToggle = () => { - if (toggleInput.checked) { - toggleSlider.style.backgroundColor = 'var(--vscode-button-background)'; - toggleKnob.style.transform = 'translateX(14px)'; - toggleKnob.style.backgroundColor = 'var(--vscode-button-foreground)'; - toggleKnob.style.opacity = '1'; - } else { - toggleSlider.style.backgroundColor = 'var(--vscode-input-background)'; - toggleKnob.style.transform = 'translateX(0)'; - toggleKnob.style.backgroundColor = 'var(--vscode-foreground)'; - toggleKnob.style.opacity = '0.6'; - } - argsMap.set('stateful', toggleInput.checked ? 'true' : 'false'); - }; - - toggleInput.addEventListener('change', updateToggle); - // Initial state - if (toggleInput.checked) { - toggleSlider.style.backgroundColor = 'var(--vscode-button-background)'; - toggleKnob.style.transform = 'translateX(14px)'; - toggleKnob.style.backgroundColor = 'var(--vscode-button-foreground)'; - toggleKnob.style.opacity = '1'; - } - - toggleSlider.appendChild(toggleKnob); - toggleLabel.appendChild(toggleInput); - toggleLabel.appendChild(toggleSlider); - - statefulWrapper.appendChild(statefulLabel); - statefulWrapper.appendChild(toggleLabel); - execGrid.appendChild(statefulWrapper); - - body.appendChild(execGrid); - - // 6. Context truncation + background execution - const advGrid = document.createElement('div'); - advGrid.style.display = 'grid'; - advGrid.style.gridTemplateColumns = '1fr 1fr'; - advGrid.style.gap = '8px'; - advGrid.style.marginTop = '8px'; - advGrid.appendChild(createSelect('truncationStrategy', 'Truncation', [ - { value: 'sliding', label: 'sliding' }, - { value: 'summarize', label: 'summarize' } - ], 'sliding')); - - const fireableWrapper = document.createElement('div'); - fireableWrapper.style.display = 'flex'; - fireableWrapper.style.flexDirection = 'column'; - fireableWrapper.style.gap = '4px'; - const fireableLabel = document.createElement('div'); - fireableLabel.textContent = 'Fireable w/o input'; - fireableLabel.style.fontSize = '10px'; - fireableLabel.style.opacity = '0.8'; - fireableLabel.style.fontWeight = '500'; - const fireableInput = document.createElement('input'); - fireableInput.type = 'number'; - fireableInput.min = '0'; - fireableInput.className = 'annotation-input'; - fireableInput.placeholder = '0'; - fireableInput.style.width = '100%'; - fireableInput.style.boxSizing = 'border-box'; - fireableInput.value = argsMap.get('fireableWithoutInput') ?? ''; - fireableInput.addEventListener('input', () => { - const val = fireableInput.value.trim(); - if (val && val !== '0') argsMap.set('fireableWithoutInput', val); - else argsMap.delete('fireableWithoutInput'); - }); - fireableWrapper.appendChild(fireableLabel); - fireableWrapper.appendChild(fireableInput); - advGrid.appendChild(fireableWrapper); - body.appendChild(advGrid); - - // 7. Backend transport. Three distinct categories — a REST API call - // (http), a one-shot CLI subprocess (*-cli), or a persistent ACP protocol - // session (acp, with a connector naming the agent; opencode-acp is the - // OpenCode-only spelling) — so they're grouped, and the CLI-only tooling - // below dims when the HTTP API is selected (it doesn't apply there). - const backendGrid = document.createElement('div'); - backendGrid.style.display = 'grid'; - backendGrid.style.gridTemplateColumns = '1fr 1fr'; - backendGrid.style.gap = '8px'; - backendGrid.style.marginTop = '8px'; - - const noneSentinel = clientBehavior().noneSentinel; - const cliToolsOptions = [ - ...(noneSentinel ? [{ value: noneSentinel, label: noneSentinel }] : []), - { value: 'native', label: 'native' } - ]; - const cliToolsWrap = createSelect('cliToolsMode', 'CLI Tools', cliToolsOptions, noneSentinel ?? 'native'); - const reasoningWrap = createSelect('reasoningEffort', 'Reasoning', [ - { value: '', label: 'default' }, - { value: 'minimal', label: 'minimal' }, - { value: 'high', label: 'high' }, - { value: 'max', label: 'max' } - ], ''); - - // The ACP connector: the agent the session speaks to. The list is what - // the runtime discovered on this machine plus what the user declared - // (clientBehavior.acpConnectors, resolved per document); a value the - // list lacks is kept as its own option so a file written elsewhere - // round-trips. Empty means the run's default (--acp-connector). - const currentConnector = stripQuotesIfStringLiteral(argsMap.get('connector') ?? '').trim(); - const connectorOptions: Array<{ value: string; label: string; group?: string }> = [ - { value: '', label: "(run's default)" } - ]; - for (const c of clientBehavior().acpConnectors ?? []) { - const detail = c.available ? '' : ' (not on PATH)'; - connectorOptions.push({ value: c.name, label: `${c.name}${detail}`, group: c.source === 'discovered' ? 'Discovered' : `Declared (${c.source})` }); - } - if (currentConnector && !connectorOptions.some((o) => o.value === currentConnector)) { - connectorOptions.push({ value: currentConnector, label: `${currentConnector} (unknown)` }); - } - const connectorWrap = createSelect('connector', 'Connector', connectorOptions, ''); - if (!clientBehavior().acpConnectors?.length) { - connectorWrap.title = 'No ACP connectors reported by the runtime; the run\'s --acp-connector applies'; - } - - // The ACP session mode (e.g. Claude: default / acceptEdits / plan / - // dontAsk / bypassPermissions): free text, since each agent has its own. - const modeWrapper = document.createElement('div'); - modeWrapper.style.display = 'flex'; - modeWrapper.style.flexDirection = 'column'; - modeWrapper.style.gap = '4px'; - const modeLabel = document.createElement('div'); - modeLabel.textContent = 'Session mode'; - modeLabel.style.fontSize = '10px'; - modeLabel.style.opacity = '0.8'; - modeLabel.style.fontWeight = '500'; - const modeInput = document.createElement('input'); - modeInput.className = 'annotation-input'; - modeInput.placeholder = "agent's default"; - modeInput.style.width = '100%'; - modeInput.style.boxSizing = 'border-box'; - modeInput.value = stripQuotesIfStringLiteral(argsMap.get('mode') ?? '').trim(); - modeInput.addEventListener('input', () => { - const val = modeInput.value.trim(); - if (val) argsMap.set('mode', toWfStringLiteral(val)); - else argsMap.delete('mode'); - }); - modeWrapper.appendChild(modeLabel); - modeWrapper.appendChild(modeInput); - - // CLI Tools / Reasoning apply to the CLI + ACP transports, not the HTTP API; - // Connector / Session mode apply to the ACP transports only. - const updateBackendRelevance = (transportVal: string): void => { - const isHttp = (transportVal || 'http') === 'http'; - for (const w of [cliToolsWrap, reasoningWrap]) { - w.style.opacity = isHttp ? '0.45' : '1'; - w.title = isHttp ? 'Applies to CLI / ACP transports, not the HTTP API' : ''; - const s = w.querySelector('select') as HTMLSelectElement | null; - if (s) s.disabled = isHttp; - } - const isAcp = transportVal === 'acp' || transportVal === 'opencode-acp'; - for (const w of [connectorWrap, modeWrapper]) { - w.style.opacity = isAcp ? '1' : '0.45'; - const s = w.querySelector('select, input') as HTMLSelectElement | HTMLInputElement | null; - if (s) s.disabled = !isAcp; - } - if (!isAcp) connectorWrap.title = 'Applies to the ACP transports'; - }; - - const transportWrap = createSelect('transport', 'Transport', [ - { value: 'http', label: 'http', group: 'REST API' }, - { value: 'acp', label: 'acp (connector)', group: 'ACP (protocol session)' }, - { value: 'opencode-acp', label: 'opencode-acp', group: 'ACP (protocol session)' }, - { value: 'opencode-cli', label: 'opencode-cli', group: 'CLI (subprocess)' }, - { value: 'claude-cli', label: 'claude-cli', group: 'CLI (subprocess)' }, - { value: 'codex-cli', label: 'codex-cli', group: 'CLI (subprocess)' } - ], 'http', updateBackendRelevance); - updateBackendRelevance(stripQuotesIfStringLiteral(argsMap.get('transport') ?? '').trim() || 'http'); - - backendGrid.appendChild(transportWrap); - backendGrid.appendChild(connectorWrap); - backendGrid.appendChild(modeWrapper); - backendGrid.appendChild(cliToolsWrap); - backendGrid.appendChild(reasoningWrap); - body.appendChild(backendGrid); - } -} - -export function describeSkillStatus(skill: WorkflowAgentSkill): string { - const desc = (skill.description ?? '').trim(); - if (desc) { - return desc; - } - return skill.hasSkillMd - ? `Skill '${skill.name}' has no description in SKILL.md.` - : `Skill '${skill.name}' is missing SKILL.md.`; -} - -function skillWarningSummary(skill: WorkflowAgentSkill): string { - const warnings: string[] = []; - if (!skill.hasSkillMd) { - warnings.push('SKILL.md missing'); - } - if ((skill.missingDeclaredHooks?.length ?? 0) > 0) { - warnings.push(`Missing scripts: ${skill.missingDeclaredHooks?.join(', ')}`); - } - if ((skill.warnings?.length ?? 0) > 0) { - warnings.push(...(skill.warnings ?? [])); - } - return warnings.join(' | '); -} - -/** - * Header row for an item card in a list editor (MCP / LSP / Output Validators): - * the item's title on the left, a remove button on the right, in normal flow. - * Replaces the old absolutely-positioned X that floated over the first field and - * clipped (tooltip and all) at the panel's right edge. - */ -export function buildRemovableCardHeader(noun: string, title: string, onRemove: () => void): HTMLElement { - const header = document.createElement('div'); - header.className = 'pp-card-head'; - const titleEl = document.createElement('span'); - titleEl.className = 'pp-card-title'; - titleEl.textContent = title; - const rm = document.createElement('button'); - rm.className = 'mini-btn icon-only danger'; - rm.innerHTML = ''; - rm.title = `Remove this ${noun}`; - rm.setAttribute('aria-label', `Remove this ${noun}`); - rm.addEventListener('click', onRemove); - header.appendChild(titleEl); - header.appendChild(rm); - return header; -} - -/** - * Inline "Removed … · Undo" bar for the list editors, so an accidental card - * removal is recoverable. Returns the bar element; the caller owns the snapshot - * state plus the restore (`onUndo`) and dismiss (`onDismiss`) behaviour. - */ -export function buildCardUndoBar(noun: string, removedTitle: string, onUndo: () => void, onDismiss: () => void): HTMLElement { - const bar = document.createElement('div'); - bar.className = 'pp-undo-bar'; - const msg = document.createElement('span'); - msg.className = 'pp-undo-msg'; - const named = (removedTitle ?? '').trim(); - msg.textContent = named ? `Removed “${named}”.` : `Removed ${noun}.`; - const actions = document.createElement('div'); - actions.className = 'pp-undo-actions'; - const undoBtn = document.createElement('button'); - undoBtn.className = 'mini-btn'; - undoBtn.textContent = 'Undo'; - undoBtn.title = `Restore this ${noun}`; - undoBtn.addEventListener('click', onUndo); - const dismissBtn = document.createElement('button'); - dismissBtn.className = 'mini-btn icon-only'; - dismissBtn.innerHTML = ''; - dismissBtn.title = 'Dismiss'; - dismissBtn.setAttribute('aria-label', 'Dismiss'); - dismissBtn.addEventListener('click', onDismiss); - actions.appendChild(undoBtn); - actions.appendChild(dismissBtn); - bar.appendChild(msg); - bar.appendChild(actions); - return bar; -} diff --git a/packages/diagram-client/src/agent-ui/agent-sections.ts b/packages/diagram-client/src/agent-ui/agent-sections.ts deleted file mode 100644 index 37a5709..0000000 --- a/packages/diagram-client/src/agent-ui/agent-sections.ts +++ /dev/null @@ -1,707 +0,0 @@ -/** - * The agent sections of a node's panel: Basic Info's Skill row, Agent Tools - * (after Annotations) and the live Agent Context (last), with the skills and - * agent presets taken from the root's snapshots before a node is drawn and the - * redraw when discovery answers for the node shown. - */ -import { html, render, nothing, type TemplateResult } from 'lit'; -import { unsafeHTML } from 'lit/directives/unsafe-html.js'; -import { WorkflowDiagramMetadata } from '@dialogram/shared'; -import { looksLikeDiff, isBlock, parseEmbedded } from '../agent-outputs'; -import { renderMarkdownSafe, looksLikeMarkdown as looksLikeMarkdownShared } from '../markdown'; -import { renderInto } from '../property-fields'; -import type { PropertyElement } from '../property-model'; -import { createPropertySection, escapeHtml, shortenPath } from '../property-section'; -import { commandId, offersRun } from '../profile'; -import { VscodeUi } from '../vscode-ui'; -import type { NodeInfoRow, NodeSectionContext, NodeSectionContribution, NodeSectionHost, PanelModelRoot } from '../node-section'; -import { - type WorkflowAgentSkill, - type WorkflowClaudeAgentProfile, - WorkflowShowAgentSkillsActionHandler, - WorkflowShowClaudeAgentsActionHandler -} from '../editing-action-handlers'; -import { describeSkillStatus } from './agent-editor'; - -export class AgentNodeSections implements NodeSectionContribution { - readonly id = 'agent'; - - /** The chat history the Agent Context last drew, so it redraws only when that changes. */ - private lastHistoryJson: string | undefined; - - install(host: NodeSectionHost): void { - window.addEventListener('dialogram.agentSkills.updated', (event: Event) => { - const detail = (event as CustomEvent<{ elementId?: string }>).detail; - const elementId = typeof detail?.elementId === 'string' ? detail.elementId : undefined; - if (elementId) { - host.redrawIfShowing(elementId); - } - }); - } - - /** - * Seed the node's skills and agent presets from the root's model-load - * snapshots, so a configured skill reads right on the first draw. A - * snapshot never replaces what discovery answered for the node, which is - * newer, and an empty one seeds nothing. - */ - prepare(node: PropertyElement, root: PanelModelRoot | undefined): void { - const skills = snapshotList(root, 'wf:agentSkillsSnapshot'); - if (skills.length > 0) { - WorkflowShowAgentSkillsActionHandler.seed(node.id, skills); - } - const agents = snapshotList(root, 'wf:claudeAgentsSnapshot'); - if (agents.length > 0) { - WorkflowShowClaudeAgentsActionHandler.seed(node.id, agents); - } - } - - /** The configured skill's status; also written back into args for downstream consumers. */ - infoRows(node: PropertyElement): readonly NodeInfoRow[] { - const args = node.args || {}; - const agentSpec = args['wf:agentSpec'] as Record | undefined; - const configuredSkill = this.extractConfiguredSkillName(agentSpec); - if (!configuredSkill) { - return []; - } - const discovered = WorkflowShowAgentSkillsActionHandler.getSkillsForElement(node.id); - const matched = discovered.find((s) => s.name === configuredSkill); - const skillStatus = matched - ? describeSkillStatus(matched) - : `Skill '${configuredSkill}' not discovered in configured roots.`; - args[WorkflowDiagramMetadata.AGENT_SKILL_STATUS] = skillStatus; - return [{ label: 'Skill', value: skillStatus }]; - } - - /** Agent Tools, for an @agent node where a run driver is behind the diagram: its two commands are the driver's. */ - afterAnnotations(node: PropertyElement, context: NodeSectionContext): HTMLElement | undefined { - return offersRun() ? this.buildAgentToolsSection(context.entityName, node.args || {}) : undefined; - } - - /** The live chat history of a stateful agent task. */ - last(node: PropertyElement): HTMLElement | undefined { - const args = node.args || {}; - this.lastHistoryJson = args[WorkflowDiagramMetadata.AGENT_CHAT_HISTORY] as string | undefined; - return this.buildAgentContextSection(args); - } - - /** While a run streams, only the Agent Context changes: redraw it in place. */ - modelChanged(node: PropertyElement, content: HTMLElement): void { - const historyJson = node.args?.[WorkflowDiagramMetadata.AGENT_CHAT_HISTORY] as string | undefined; - if (historyJson !== this.lastHistoryJson) { - this.lastHistoryJson = historyJson; - this.refreshAgentContext(node, content); - } - } - - - /** - * Streaming update for the agent context, instead of redrawing the whole - * panel: re-render the lit body into the existing section host so lit diffs - * it — preserving open `
`, scroll position, and DOM identity. - * Builds the section if it isn't present yet. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected refreshAgentContext(node: any, content: HTMLElement): void { - const args = node.args || {}; - const parsed = this.parseAgentContext(args); - const existing = content.querySelector('.agent-context-live-section') as HTMLElement | null; - - if (!parsed) { - existing?.remove(); - return; - } - - if (existing) { - const titleEl = existing.querySelector('.section-title'); - if (titleEl) titleEl.textContent = this.agentSectionTitle(parsed); - const host = existing.querySelector('.property-section-content') as HTMLElement | null; - if (host) { - render(this.agentContextBody(parsed), host); - this.scrollAgentToLatest(existing); - } - return; - } - - const section = this.buildAgentContextSection(args); - if (!section) return; - content.appendChild(section); - this.scrollAgentToLatest(section); - } - - /** Parse the agent-context args, or null when there is no (valid) chat history. */ - protected parseAgentContext(args: Record): { - chatHistory: Array<{ role: string; content: string; thinking?: string }>; - fireCount?: number; - contextBudget?: number; - truncationStrategy?: string; - agentModel?: string; - } | null { - const raw = args[WorkflowDiagramMetadata.AGENT_CHAT_HISTORY] as string | undefined; - if (!raw) return null; - try { - const chatHistory = JSON.parse(raw) as Array<{ role: string; content: string; thinking?: string }>; - return { - chatHistory, - fireCount: args[WorkflowDiagramMetadata.AGENT_FIRE_COUNT] as number | undefined, - contextBudget: args[WorkflowDiagramMetadata.AGENT_CONTEXT_BUDGET] as number | undefined, - truncationStrategy: args[WorkflowDiagramMetadata.AGENT_TRUNCATION_STRATEGY] as string | undefined, - agentModel: args[WorkflowDiagramMetadata.AGENT_MODEL] as string | undefined - }; - } catch { - return null; // Malformed chat history JSON — skip silently. - } - } - - protected agentSectionTitle(parsed: { chatHistory: unknown[]; contextBudget?: number }): string { - return `Agent Context (${parsed.chatHistory.length}${parsed.contextBudget ? '/' + parsed.contextBudget : ''})`; - } - - /** - * Build the live "Agent Context" section as a lit template rendered into a stable - * section host. Single source of truth used by both the full render and the - * streaming refresh. Returns undefined when there is no (valid) chat history. - */ - protected buildAgentContextSection(args: Record): HTMLElement | undefined { - const parsed = this.parseAgentContext(args); - if (!parsed) return undefined; - const section = createPropertySection(this.agentSectionTitle(parsed)); - section.classList.add('agent-context-live-section'); - const host = section.querySelector('.property-section-content') as HTMLElement | null; - if (host) { - render(this.agentContextBody(parsed), host); - } - return section; - } - - /** The badges + chat-timeline body of the agent-context section (lit-diffed on stream). */ - protected agentContextBody(parsed: { - chatHistory: Array<{ role: string; content: string; thinking?: string }>; - fireCount?: number; - contextBudget?: number; - truncationStrategy?: string; - agentModel?: string; - }): TemplateResult { - const { chatHistory, fireCount, contextBudget, truncationStrategy, agentModel } = parsed; - return html` -
- ${fireCount !== undefined ? this.agentBadge('flame', `${fireCount} firing${fireCount !== 1 ? 's' : ''}`) : nothing} - ${agentModel ? this.agentBadge('hubot', agentModel) : nothing} - ${truncationStrategy ? this.agentBadge('fold', truncationStrategy) : nothing} - ${contextBudget ? this.agentBadge('graph', `${chatHistory.length}/${contextBudget} msgs`) : nothing} -
-
- ${chatHistory.map((msg) => this.agentMessageCard(msg))} -
- `; - } - - protected agentBadge(icon: string, text: string): TemplateResult { - return html` ${text}`; - } - - /** - * One chat message card. Chain-of-thought and the (collapsed-by-default) system - * prompt are native `
`, so their open/closed state survives lit re-renders - * during streaming. The message content reuses the existing imperative renderer via - * `renderInto`, keyed on content so only a changed (streaming) message re-renders. - */ - protected agentMessageCard(msg: { role: string; content: string; thinking?: string }): TemplateResult { - const roleIcons: Record = { system: 'settings-gear', user: 'arrow-right', assistant: 'comment', tool: 'tools' }; - const roleLabels: Record = { system: 'System Prompt', user: 'Input', assistant: 'Response', tool: 'Tool Result' }; - const icon = roleIcons[msg.role] ?? 'comment'; - const label = roleLabels[msg.role] ?? msg.role; - const isSystem = msg.role === 'system'; - - const thinking = msg.thinking && msg.thinking.length > 0 - ? html` -
- - - Chain of Thought - - -
- ${this.looksLikeMarkdown(msg.thinking) - ? html`
${unsafeHTML(renderMarkdownSafe(msg.thinking))}
` - : html`
${msg.thinking}
`} -
-
` - : nothing; - - const contentBody = html`
this.renderAgentMessageContent(el, msg.content, msg.role), msg.content)}>
`; - - if (isSystem) { - return html` -
- - - ${label} - - -
- ${thinking} - ${contentBody} -
-
`; - } - - return html` -
-
- - ${label} -
-
- ${thinking} - ${contentBody} -
-
`; - } - - protected scrollAgentToLatest(section: HTMLElement): void { - const container = section.querySelector('.agent-chat-container'); - container?.lastElementChild?.scrollIntoView({ behavior: 'smooth', block: 'end' }); - } - - /** - * Render agent message content with smart formatting: - * - JSON objects are shown as structured key-value pairs - * - File paths are shortened to basename - * - Long text content is collapsible - */ - protected renderAgentMessageContent(container: HTMLElement, content: string, role: string): void { - // Try to parse as JSON for structured rendering. - let parsed: unknown; - try { parsed = JSON.parse(content); } catch { /* not JSON */ } - - if (parsed && typeof parsed === 'object' && parsed !== null) { - this.renderJsonContent(container, parsed as Record, role); - return; - } - - // Detect markdown content and render as formatted HTML. - if (this.looksLikeMarkdown(content)) { - const mdDiv = document.createElement('div'); - mdDiv.className = 'agent-md-content'; - mdDiv.innerHTML = renderMarkdownSafe(content); - container.appendChild(mdDiv); - return; - } - - // Plain text rendering with collapse for long content. - const maxPreviewLen = 400; - if (content.length <= maxPreviewLen) { - const pre = document.createElement('div'); - pre.className = 'agent-chat-text'; - pre.textContent = content; - container.appendChild(pre); - return; - } - - const pre = document.createElement('div'); - pre.className = 'agent-chat-text agent-chat-collapsible'; - pre.textContent = content.slice(0, maxPreviewLen) + '…'; - let expanded = false; - const toggle = document.createElement('button'); - toggle.className = 'agent-chat-toggle'; - toggle.textContent = 'Show more'; - toggle.addEventListener('click', (e) => { - e.stopPropagation(); - expanded = !expanded; - pre.textContent = expanded ? content : content.slice(0, maxPreviewLen) + '…'; - toggle.textContent = expanded ? 'Show less' : 'Show more'; - }); - container.appendChild(pre); - container.appendChild(toggle); - } - - /** - * Render a JSON object as structured key-value items. - * Handles common agent patterns like inputs/outputs/fileInputs. - */ - protected renderJsonContent(container: HTMLElement, obj: Record, role: string): void { - // For user messages: extract the meaningful parts (inputs, task, instance). - if (role === 'user') { - const instance = typeof obj['instance'] === 'string' ? obj['instance'] : undefined; - const task = typeof obj['task'] === 'string' ? obj['task'] : undefined; - if (instance || task) { - const metaDiv = document.createElement('div'); - metaDiv.className = 'agent-json-meta'; - if (task) { metaDiv.innerHTML += `task ${escapeHtml(task)}`; } - if (instance) { metaDiv.innerHTML += `instance ${escapeHtml(instance)}`; } - container.appendChild(metaDiv); - } - - // Show inputs as key→value pills. - const inputs = obj['inputs'] as Record | undefined; - if (inputs && typeof inputs === 'object') { - const inputsSec = document.createElement('div'); - inputsSec.className = 'agent-json-section'; - inputsSec.innerHTML = '
Inputs
'; - for (const [key, val] of Object.entries(inputs)) { - const row = document.createElement('div'); - row.className = 'agent-json-kv'; - row.innerHTML = `${escapeHtml(key)}`; - const valSpan = document.createElement('span'); - valSpan.className = 'agent-json-val'; - valSpan.textContent = shortenPath(String(val)); - valSpan.title = String(val); - row.appendChild(valSpan); - inputsSec.appendChild(row); - } - container.appendChild(inputsSec); - } - - // Show file content if present (collapsed by default). - const fileInputs = obj['fileInputs'] as Record | undefined; - if (fileInputs && typeof fileInputs === 'object') { - for (const [key, fileObj] of Object.entries(fileInputs)) { - if (!fileObj || typeof fileObj !== 'object') continue; - const fc = fileObj as Record; - const filePath = typeof fc['path'] === 'string' ? fc['path'] : undefined; - const fileContent = typeof fc['content'] === 'string' ? fc['content'] : undefined; - if (!fileContent) continue; - const fileDiv = document.createElement('div'); - fileDiv.className = 'agent-json-file'; - const fileHeader = document.createElement('button'); - fileHeader.className = 'agent-json-file-header'; - fileHeader.innerHTML = ` ${escapeHtml(key)} ${escapeHtml(shortenPath(filePath ?? key))} `; - const filePre = document.createElement('div'); - filePre.className = 'agent-json-file-content'; - filePre.textContent = fileContent; - filePre.style.display = 'none'; - fileHeader.addEventListener('click', () => { - const visible = filePre.style.display !== 'none'; - filePre.style.display = visible ? 'none' : 'block'; - const arrow = fileHeader.querySelector('.agent-chat-expand-arrow'); - if (arrow) { arrow.classList.toggle('expanded', !visible); } - }); - fileDiv.appendChild(fileHeader); - fileDiv.appendChild(filePre); - container.appendChild(fileDiv); - } - } - return; - } - - // For assistant messages: detect outputs wrapper. - if (role === 'assistant') { - const outputs = obj['outputs'] as Record | undefined; - const target = outputs ?? obj; - this.renderJsonTree(container, target, 0); - return; - } - - // Generic: render as indented tree. - this.renderJsonTree(container, obj, 0); - } - - /** - * Render a JSON value as a lightweight tree with collapsible objects. - */ - protected renderJsonTree(container: HTMLElement, value: unknown, depth: number): void { - if (value === null || value === undefined) { - const span = document.createElement('span'); - span.className = 'agent-json-null'; - span.textContent = 'null'; - container.appendChild(span); - return; - } - if (typeof value === 'string') { - // A port's value is itself JSON: unwrap it and show its fields - // rather than one escaped line. - const embedded = parseEmbedded(value); - if (embedded !== value) { - this.renderJsonTree(container, embedded, depth); - return; - } - // A diff must not go through Markdown: its removed lines begin - // with `-` and would render as a bullet list. - if (looksLikeDiff(value) || isBlock(value)) { - const pre = document.createElement('pre'); - pre.className = looksLikeDiff(value) ? 'agent-json-pre agent-json-diff' : 'agent-json-pre'; - pre.textContent = value.replace(/\s+$/, ''); - container.appendChild(pre); - return; - } - // Detect markdown content and render as formatted HTML. - if (this.looksLikeMarkdown(value)) { - const mdDiv = document.createElement('div'); - mdDiv.className = 'agent-md-content'; - mdDiv.innerHTML = renderMarkdownSafe(value); - container.appendChild(mdDiv); - return; - } - const span = document.createElement('span'); - span.className = 'agent-json-string'; - const truncLimit = 500; - const display = value.length > truncLimit ? value.slice(0, truncLimit) + '…' : value; - span.textContent = display; - if (value.length > truncLimit) { - span.title = 'Click to expand'; - span.style.cursor = 'pointer'; - let exp = false; - span.addEventListener('click', () => { exp = !exp; span.textContent = exp ? value : display; }); - } - container.appendChild(span); - return; - } - if (typeof value === 'number' || typeof value === 'boolean') { - const span = document.createElement('span'); - span.className = 'agent-json-primitive'; - span.textContent = String(value); - container.appendChild(span); - return; - } - if (Array.isArray(value)) { - if (value.length === 0) { - const span = document.createElement('span'); - span.className = 'agent-json-null'; - span.textContent = '[]'; - container.appendChild(span); - return; - } - const list = document.createElement('div'); - list.className = 'agent-json-array'; - for (let i = 0; i < value.length; i++) { - const item = document.createElement('div'); - item.className = 'agent-json-array-item'; - const idx = document.createElement('span'); - idx.className = 'agent-json-idx'; - idx.textContent = `${i + 1}.`; - item.appendChild(idx); - const val = document.createElement('span'); - val.className = 'agent-json-array-val'; - this.renderJsonTree(val, value[i], depth + 1); - item.appendChild(val); - list.appendChild(item); - } - container.appendChild(list); - return; - } - if (typeof value === 'object') { - const entries = Object.entries(value); - const table = document.createElement('div'); - table.className = 'agent-json-object'; - for (const [k, v] of entries) { - // Use stacked (vertical) layout for long strings or nested objects. - const isComplex = (typeof v === 'string' && v.length > 80) - || (typeof v === 'object' && v !== null); - const row = document.createElement('div'); - row.className = isComplex ? 'agent-json-kv agent-json-kv-stacked' : 'agent-json-kv'; - const keySpan = document.createElement('span'); - keySpan.className = 'agent-json-key'; - keySpan.textContent = k; - row.appendChild(keySpan); - const valSpan = document.createElement('div'); - valSpan.className = 'agent-json-tree-val'; - this.renderJsonTree(valSpan, v, depth + 1); - row.appendChild(valSpan); - table.appendChild(row); - } - container.appendChild(table); - } - } - - /** - * Heuristic: does this string look like markdown? - * Checks for headings, bold, lists, code fences, horizontal rules. - */ - protected looksLikeMarkdown(text: string): boolean { - if (text.length < 20) return false; - return looksLikeMarkdownShared(text); - } - - /** - * Build a property-panel section for configuring agent tool-calling. - * Shows a checkbox to enable/disable tools and a dropdown for auth mode. - * Changes are dispatched to the extension host via NavigateToExternalTargetAction. - */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - protected buildAgentToolsSection(entityName: string, args: any): HTMLElement | undefined { - const defAnnotations = (args?.[WorkflowDiagramMetadata.ENTITY_DEFINITION_ANNOTATIONS] as any[]) || []; - const isAgent = Array.isArray(defAnnotations) && defAnnotations.some( - (a: { name?: string }) => a?.name === 'agent' - ); - if (!isAgent) { - return undefined; - } - - const section = createPropertySection('Agent Tools'); - const sectionContent = section.querySelector('.property-section-content'); - if (!sectionContent) { return section; } - - // Container - const container = document.createElement('div'); - container.style.display = 'flex'; - container.style.alignItems = 'center'; - container.style.justifyContent = 'space-between'; - - // 1. Enabled Toggle - const toggleGroup = document.createElement('div'); - toggleGroup.style.display = 'flex'; - toggleGroup.style.alignItems = 'center'; - toggleGroup.style.gap = '8px'; - - const enableLabel = document.createElement('span'); - enableLabel.textContent = 'Enabled'; - enableLabel.className = 'property-label'; - - const toggleWrap = document.createElement('label'); - toggleWrap.style.display = 'inline-block'; - toggleWrap.style.position = 'relative'; - toggleWrap.style.width = '32px'; - toggleWrap.style.height = '18px'; - toggleWrap.style.cursor = 'pointer'; - - const enableCheckbox = document.createElement('input'); - enableCheckbox.type = 'checkbox'; - enableCheckbox.style.opacity = '0'; - enableCheckbox.style.width = '0'; - enableCheckbox.style.height = '0'; - - const toggleSlider = document.createElement('span'); - toggleSlider.style.position = 'absolute'; - toggleSlider.style.top = '0'; - toggleSlider.style.left = '0'; - toggleSlider.style.right = '0'; - toggleSlider.style.bottom = '0'; - toggleSlider.style.backgroundColor = 'var(--vscode-input-background)'; - toggleSlider.style.border = '1px solid var(--vscode-input-border)'; - toggleSlider.style.transition = '.2s'; - toggleSlider.style.borderRadius = '18px'; - - const toggleKnob = document.createElement('span'); - toggleKnob.style.position = 'absolute'; - toggleKnob.style.height = '12px'; - toggleKnob.style.width = '12px'; - toggleKnob.style.left = '3px'; - toggleKnob.style.bottom = '2px'; - toggleKnob.style.backgroundColor = 'var(--vscode-foreground)'; - toggleKnob.style.transition = '.2s'; - toggleKnob.style.borderRadius = '50%'; - toggleKnob.style.opacity = '0.6'; - - toggleSlider.appendChild(toggleKnob); - toggleWrap.appendChild(enableCheckbox); - toggleWrap.appendChild(toggleSlider); - - toggleGroup.appendChild(enableLabel); - toggleGroup.appendChild(toggleWrap); - - // 2. Auth Mode - const authGroup = document.createElement('div'); - authGroup.style.display = 'flex'; - authGroup.style.alignItems = 'center'; - authGroup.style.gap = '6px'; - - const authLabel = document.createElement('span'); - authLabel.textContent = 'Auth Mode'; - authLabel.style.fontSize = '11px'; - authLabel.style.opacity = '0.8'; - - const authSelect = document.createElement('select'); - authSelect.className = 'annotation-input'; - authSelect.style.width = '100px'; - authSelect.style.padding = '2px 4px'; - authSelect.style.height = '22px'; - - for (const opt of ['deny-all', 'allow-all', 'policy'] as const) { - const o = document.createElement('option'); - o.value = opt; - o.textContent = opt; - if (opt === 'allow-all') { o.selected = true; } - authSelect.appendChild(o); - } - - authGroup.appendChild(authLabel); - authGroup.appendChild(authSelect); - - container.appendChild(toggleGroup); - container.appendChild(authGroup); - sectionContent.appendChild(container); - - // State Management - const updateState = () => { - const enabled = enableCheckbox.checked; - - // Toggle Visuals - if (enabled) { - toggleSlider.style.backgroundColor = 'var(--vscode-button-background)'; - toggleKnob.style.transform = 'translateX(14px)'; - toggleKnob.style.backgroundColor = 'var(--vscode-button-foreground)'; - toggleKnob.style.opacity = '1'; - } else { - toggleSlider.style.backgroundColor = 'var(--vscode-input-background)'; - toggleKnob.style.transform = 'translateX(0)'; - toggleKnob.style.backgroundColor = 'var(--vscode-foreground)'; - toggleKnob.style.opacity = '0.6'; - } - - // Auth Dropdown State - authSelect.disabled = !enabled; - authSelect.style.opacity = enabled ? '1' : '0.5'; - authLabel.style.opacity = enabled ? '0.9' : '0.5'; - }; - - const dispatch = () => { - void VscodeUi.instance.executeCommand( - commandId('setAgentToolConfig'), - [{ entityName, enabled: enableCheckbox.checked, auth: authSelect.value }] - ); - }; - - enableCheckbox.addEventListener('change', () => { - updateState(); - dispatch(); - }); - - authSelect.addEventListener('change', () => { - dispatch(); - }); - - // Initial State - updateState(); - - // Hydrate - void VscodeUi.instance.executeCommand<{ enabled: boolean; auth: string }>( - commandId('getAgentToolConfig'), - [entityName] - ).then(state => { - if (state) { - enableCheckbox.checked = state.enabled; - if (state.auth) authSelect.value = state.auth; - updateState(); - } - }); - - return section; - } - - protected extractConfiguredSkillName(agentSpec: Record | undefined): string { - if (!agentSpec || typeof agentSpec !== 'object') { - return ''; - } - const raw = agentSpec['skill']; - if (typeof raw === 'string') { - return raw.trim(); - } - return ''; - } -} - -/** A JSON array the root carries as a string, or none when absent or malformed. */ -function snapshotList(root: PanelModelRoot | undefined, key: string): T[] { - const raw = root?.args?.[key]; - if (typeof raw !== 'string' || raw === '') { - return []; - } - try { - const parsed = JSON.parse(raw); - return Array.isArray(parsed) ? parsed as T[] : []; - } catch { - return []; // a best-effort snapshot - } -} diff --git a/packages/diagram-client/src/agent-ui/index.ts b/packages/diagram-client/src/agent-ui/index.ts deleted file mode 100644 index 51b2faa..0000000 --- a/packages/diagram-client/src/agent-ui/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * The agent workflow UI: the `@tool` and `@agent` annotation editors, the agent - * sections of a node's panel, and the wizard's `@tool` and `@viewer` templates. - * - * `agentUiModule` binds all of it. The stock bundle does not load it: a - * product's own webview bundle passes it to - * `startStockDiagram({ features: [agentUiModule] })`, imported by path as - * `@dialogram/diagram-client/agent-ui/index`. - */ -import { ContainerModule, type interfaces } from 'inversify'; -import { configureActionHandler } from '@eclipse-glsp/client'; -import { AnnotationEditor } from '../annotation-editor'; -import { AnnotationTemplate } from '../annotation-template'; -import { NodeSectionContribution } from '../node-section'; -import { - WorkflowShowAgentSkillsAction, - WorkflowShowAgentSkillsActionHandler, - WorkflowShowClaudeAgentsAction, - WorkflowShowClaudeAgentsActionHandler -} from '../editing-action-handlers'; -import { AgentAnnotationEditor } from './agent-editor'; -import { AgentNodeSections } from './agent-sections'; -import { ToolAnnotationEditor } from './tool-editor'; -import { ToolAnnotationTemplate } from './tool-template'; -import { ViewerAnnotationTemplate } from './viewer-template'; - -/** Fresh instances, in the order the panel shows them. */ -export function agentUiEditors(): AnnotationEditor[] { - return [new ToolAnnotationEditor(), new AgentAnnotationEditor()]; -} - -/** A fresh instance: it keeps the chat history it last drew. */ -export function agentUiNodeSections(): NodeSectionContribution[] { - return [new AgentNodeSections()]; -} - -/** The wizard's templates, in its menu order. */ -export function agentUiTemplates(): AnnotationTemplate[] { - return [new ToolAnnotationTemplate(), new ViewerAnnotationTemplate()]; -} - -/** What a feature module's registry is handed, as GLSP's helpers take it. */ -export interface ModuleContext { - bind: interfaces.Bind; - unbind: interfaces.Unbind; - isBound: interfaces.IsBound; - rebind: interfaces.Rebind; -} - -/** - * Bind fresh instances of all of it into a feature module, and the handlers - * of the server's answers to the `@agent` editor's discovery requests (the - * skills and agent presets it found), whose caches the editor and the Skill - * row read. - */ -export function bindAgentUi(context: ModuleContext): void { - const { bind } = context; - configureActionHandler(context, WorkflowShowAgentSkillsAction.KIND, WorkflowShowAgentSkillsActionHandler); - configureActionHandler(context, WorkflowShowClaudeAgentsAction.KIND, WorkflowShowClaudeAgentsActionHandler); - for (const editor of agentUiEditors()) { - bind(AnnotationEditor).toConstantValue(editor); - } - for (const sections of agentUiNodeSections()) { - bind(NodeSectionContribution).toConstantValue(sections); - } - for (const template of agentUiTemplates()) { - bind(AnnotationTemplate).toConstantValue(template); - } -} - -/** The agent workflow UI as a feature module of its own. */ -export const agentUiModule = new ContainerModule((bind, unbind, isBound, rebind) => bindAgentUi({ bind, unbind, isBound, rebind })); diff --git a/packages/diagram-client/src/agent-ui/literals.ts b/packages/diagram-client/src/agent-ui/literals.ts deleted file mode 100644 index 26c3f76..0000000 --- a/packages/diagram-client/src/agent-ui/literals.ts +++ /dev/null @@ -1,174 +0,0 @@ -/** - * Literal helpers for the annotation editors: an argument value is the - * source expression as written (`"bash"`, `["-lc", ""]`), and the editors show - * and edit the value it denotes. - */ - -export function stripQuotesIfStringLiteral(expr: string): string { - const trimmed = (expr ?? '').trim(); - if (trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) { - const inner = trimmed.slice(1, -1); - let out = ''; - for (let i = 0; i < inner.length; i++) { - const ch = inner[i]; - if (ch !== '\\') { - out += ch; - continue; - } - const next = inner[i + 1]; - if (next === undefined) { - out += '\\'; - continue; - } - if (next === 'n') { - out += '\n'; - i++; - continue; - } - if (next === 'r') { - out += '\r'; - i++; - continue; - } - if (next === 't') { - out += '\t'; - i++; - continue; - } - if (next === '"' || next === '\\') { - out += next; - i++; - continue; - } - out += next; - i++; - } - return out; - } - return trimmed; -} - -export function toWfStringLiteral(value: string): string { - const s = (value ?? '') - .replace(/\\/g, '\\\\') - .replace(/\r/g, '\\r') - .replace(/\n/g, '\\n') - .replace(/\t/g, '\\t') - .replace(/"/g, '\\"'); - return `"${s}"`; -} - -export function normalizeMultilineStringForEditor(value: string): string { - const normalized = (value ?? '').replace(/\r\n?/g, '\n'); - if (!normalized.includes('\n')) { - return normalized; - } - - const lines = normalized.split('\n'); - if (lines.length <= 1) { - return normalized; - } - - const trailing = lines.slice(1); - const nonEmptyTrailing = trailing.filter(line => line.trim().length > 0); - if (nonEmptyTrailing.length === 0) { - return normalized; - } - - const minIndent = nonEmptyTrailing.reduce((min, line) => { - const m = line.match(/^[\t ]*/); - const indent = m ? m[0].length : 0; - return Math.min(min, indent); - }, Number.POSITIVE_INFINITY); - - if (!Number.isFinite(minIndent) || minIndent <= 0) { - return normalized; - } - - const dedentedTrailing = trailing.map(line => { - if (line.trim().length === 0) { - return ''; - } - const m = line.match(/^[\t ]*/); - const indent = m ? m[0].length : 0; - return line.slice(Math.min(indent, minIndent)); - }); - - return [lines[0], ...dedentedTrailing].join('\n'); -} - -export function tryParseWfStringListExpr(expr: string): string[] | undefined { - const trimmed = (expr ?? '').trim(); - if (!trimmed.startsWith('[') || !trimmed.endsWith(']')) { - return undefined; - } - - // Very small parser for list of *string literals* only: ["a", "b"] - // Supports escaped \" and \\ inside strings. - const inner = trimmed.slice(1, -1).trim(); - if (!inner) { - return []; - } - - const items: string[] = []; - let i = 0; - const n = inner.length; - const skipWs = () => { - while (i < n && /\s/.test(inner[i])) i++; - }; - - const parseString = (): string | undefined => { - if (inner[i] !== '"') { - return undefined; - } - i++; // skip opening quote - let out = ''; - while (i < n) { - const ch = inner[i]; - if (ch === '"') { - i++; - return out; - } - if (ch === '\\') { - const next = inner[i + 1]; - if (next === '"' || next === '\\') { - out += next; - i += 2; - continue; - } - // Unknown escape: keep as-is. - out += ch; - i++; - continue; - } - out += ch; - i++; - } - return undefined; - }; - - while (i < n) { - skipWs(); - const s = parseString(); - if (s === undefined) { - return undefined; - } - items.push(s); - skipWs(); - if (i >= n) { - break; - } - if (inner[i] === ',') { - i++; - continue; - } - // Unexpected token. - return undefined; - } - return items; -} - -export function serializeWfStringListExpr(items: string[]): string { - const parts = (items ?? []).map(v => toWfStringLiteral(v)); - return `[${parts.join(', ')}]`; -} diff --git a/packages/diagram-client/src/agent-ui/tool-editor.ts b/packages/diagram-client/src/agent-ui/tool-editor.ts deleted file mode 100644 index 90a8a89..0000000 --- a/packages/diagram-client/src/agent-ui/tool-editor.ts +++ /dev/null @@ -1,254 +0,0 @@ -/** - * The `@tool` annotation's editor: the command, its argument list and whether - * it inherits stdio. - */ -import type { AnnotationEditor, AnnotationEditorContext } from '../annotation-editor'; -import { VscodeUi } from '../vscode-ui'; -import { serializeWfStringListExpr, stripQuotesIfStringLiteral, toWfStringLiteral, tryParseWfStringListExpr } from './literals'; - -export class ToolAnnotationEditor implements AnnotationEditor { - readonly annotationName = 'tool'; - readonly reservedArgs = ['cmd', 'args', 'inheritStdio']; - readonly mergeArgUpdates = true; - readonly quickAdd = { - label: 'Add @tool', - title: 'Add or update @tool annotation', - annotationText: '@tool(\n cmd="bash",\n args=["-lc", ""],\n inheritStdio=true\n)' - }; - - /** `@tool(cmd=..., args=..., inherit_stdio=...)` first, in that order, then the rest. */ - formatAnnotation(argsMap: ReadonlyMap, pairs: ReadonlyArray): string { - const cmd = argsMap.get('cmd')?.trim(); - const argsExpr = argsMap.get('args')?.trim(); - const inherit = argsMap.get('inheritStdio')?.trim() ?? argsMap.get('inherit_stdio')?.trim(); - - const ordered: Array<[string, string]> = []; - if (cmd) ordered.push(['cmd', cmd]); - if (argsExpr) ordered.push(['args', argsExpr]); - if (inherit) { - const normalizedBool = /^(true|false)$/i.test(inherit) - ? (inherit.toLowerCase() === 'true' ? 'True' : 'False') - : inherit; - ordered.push(['inherit_stdio', normalizedBool]); - } - - for (const [k, v] of pairs) { - if (k === 'cmd' || k === 'args' || k === 'inheritStdio' || k === 'inherit_stdio') continue; - ordered.push([k, v]); - } - - return ordered.length === 0 - ? '@tool' - : `@tool(\n${ordered.map(([k, v]) => ` ${k}=${v}`).join(',\n')}\n)`; - } - - render(context: AnnotationEditorContext): void { - const { args: argsMap, body } = context; - - // cmd (string) - const cmdRow = document.createElement('div'); - cmdRow.className = 'annotation-field'; - - const cmdLabel = document.createElement('div'); - cmdLabel.className = 'annotation-label'; - cmdLabel.textContent = 'cmd (string)'; - - const cmdInput = document.createElement('input'); - cmdInput.className = 'annotation-input'; - cmdInput.placeholder = 'e.g. bash'; - cmdInput.value = stripQuotesIfStringLiteral(argsMap.get('cmd') ?? ''); - cmdInput.addEventListener('input', () => { - argsMap.set('cmd', toWfStringLiteral(cmdInput.value)); - }); - - cmdRow.appendChild(cmdLabel); - cmdRow.appendChild(cmdInput); - body.appendChild(cmdRow); - - // args (list of strings) + raw expression fallback - const argsContainer = document.createElement('div'); - argsContainer.className = 'annotation-args-container'; - - const argsHeader = document.createElement('div'); - argsHeader.className = 'annotation-args-header'; - - const argsTitle = document.createElement('div'); - argsTitle.className = 'annotation-args-title'; - argsTitle.textContent = 'args (list)'; - - const argsMode = document.createElement('div'); - argsMode.className = 'annotation-args-mode'; - - const toRawBtn = document.createElement('button'); - toRawBtn.type = 'button'; - toRawBtn.className = 'mini-btn'; - toRawBtn.textContent = 'Raw…'; - - const toListBtn = document.createElement('button'); - toListBtn.type = 'button'; - toListBtn.className = 'mini-btn'; - toListBtn.textContent = 'List…'; - - argsMode.appendChild(toListBtn); - argsMode.appendChild(toRawBtn); - argsHeader.appendChild(argsTitle); - argsHeader.appendChild(argsMode); - - const argsHelp = document.createElement('div'); - argsHelp.className = 'annotation-args-help'; - argsHelp.textContent = 'Each row is one CLI argument (string).'; - - const argsList = document.createElement('div'); - argsList.className = 'annotation-args-list'; - - const addArgBtn = document.createElement('button'); - addArgBtn.type = 'button'; - addArgBtn.className = 'mini-btn'; - addArgBtn.textContent = 'Add item'; - - const rawRow = document.createElement('div'); - rawRow.className = 'annotation-field'; - const rawLabel = document.createElement('div'); - rawLabel.className = 'annotation-label'; - rawLabel.textContent = 'args (expression)'; - const rawTextarea = document.createElement('textarea'); - rawTextarea.className = 'annotation-input annotation-textarea'; - rawTextarea.rows = 3; - rawTextarea.placeholder = 'e.g. ["-lc", "{in.exe} {in.input} > {out.stdout}"]'; - rawRow.appendChild(rawLabel); - rawRow.appendChild(rawTextarea); - - let argsItems: string[] = []; - let mode: 'list' | 'raw' = 'list'; - - const renderArgsList = () => { - argsList.innerHTML = ''; - argsItems.forEach((val, idx) => { - const row = document.createElement('div'); - row.className = 'annotation-args-item'; - - const idxEl = document.createElement('div'); - idxEl.className = 'annotation-args-index'; - idxEl.textContent = String(idx); - - const inp = document.createElement('input'); - inp.className = 'annotation-input annotation-args-input'; - inp.value = val; - inp.placeholder = 'argument'; - inp.addEventListener('input', () => { - argsItems[idx] = inp.value; - argsMap.set('args', serializeWfStringListExpr(argsItems)); - }); - - const rm = document.createElement('button'); - rm.type = 'button'; - rm.className = 'mini-btn danger icon-only'; - rm.title = 'Remove item'; - rm.setAttribute('aria-label', 'Remove item'); - const rmIcon = document.createElement('span'); - rmIcon.className = 'codicon codicon-remove'; - rm.appendChild(rmIcon); - rm.addEventListener('click', () => { - argsItems.splice(idx, 1); - argsMap.set('args', serializeWfStringListExpr(argsItems)); - renderArgsList(); - }); - - row.appendChild(idxEl); - row.appendChild(inp); - row.appendChild(rm); - argsList.appendChild(row); - }); - }; - - const setMode = (next: 'list' | 'raw') => { - mode = next; - const showList = mode === 'list'; - argsList.style.display = showList ? '' : 'none'; - addArgBtn.style.display = showList ? '' : 'none'; - argsHelp.style.display = showList ? '' : 'none'; - rawRow.style.display = showList ? 'none' : ''; - }; - - addArgBtn.addEventListener('click', () => { - argsItems.push(''); - argsMap.set('args', serializeWfStringListExpr(argsItems)); - renderArgsList(); - }); - - rawTextarea.addEventListener('input', () => { - argsMap.set('args', rawTextarea.value); - }); - - toRawBtn.addEventListener('click', () => { - rawTextarea.value = argsMap.get('args') ?? ''; - setMode('raw'); - }); - - toListBtn.addEventListener('click', () => { - const parsed = tryParseWfStringListExpr(argsMap.get('args') ?? ''); - if (!parsed) { - VscodeUi.instance.infoMessage('Cannot switch to list mode: args is not a list of string literals.'); - setMode('raw'); - return; - } - argsItems = parsed; - argsMap.set('args', serializeWfStringListExpr(argsItems)); - renderArgsList(); - setMode('list'); - }); - - // Initialize - const initialArgsExpr = argsMap.get('args') ?? ''; - const parsed = tryParseWfStringListExpr(initialArgsExpr); - if (parsed) { - argsItems = parsed; - argsMap.set('args', serializeWfStringListExpr(argsItems)); - renderArgsList(); - setMode('list'); - } else { - rawTextarea.value = initialArgsExpr; - setMode('raw'); - } - - argsContainer.appendChild(argsHeader); - argsContainer.appendChild(argsHelp); - argsContainer.appendChild(argsList); - argsContainer.appendChild(addArgBtn); - argsContainer.appendChild(rawRow); - body.appendChild(argsContainer); - - // inheritStdio (bool) - const inheritRow = document.createElement('div'); - inheritRow.className = 'annotation-field'; - const inheritLabel = document.createElement('div'); - inheritLabel.className = 'annotation-label'; - inheritLabel.textContent = 'inheritStdio (bool)'; - - const inheritWrap = document.createElement('div'); - inheritWrap.className = 'annotation-checkbox-wrap'; - - const inherit = document.createElement('input'); - inherit.type = 'checkbox'; - inherit.className = 'annotation-checkbox'; - const cur = (argsMap.get('inheritStdio') ?? '').trim().toLowerCase(); - inherit.checked = cur === 'true'; - argsMap.set('inheritStdio', inherit.checked ? 'true' : 'false'); - inherit.addEventListener('change', () => { - argsMap.set('inheritStdio', inherit.checked ? 'true' : 'false'); - }); - - const inheritText = document.createElement('div'); - inheritText.className = 'annotation-checkbox-text'; - inheritText.textContent = inherit.checked ? 'true' : 'false'; - inherit.addEventListener('change', () => { - inheritText.textContent = inherit.checked ? 'true' : 'false'; - }); - - inheritWrap.appendChild(inherit); - inheritWrap.appendChild(inheritText); - inheritRow.appendChild(inheritLabel); - inheritRow.appendChild(inheritWrap); - body.appendChild(inheritRow); - } -} diff --git a/packages/diagram-client/src/agent-ui/tool-template.ts b/packages/diagram-client/src/agent-ui/tool-template.ts deleted file mode 100644 index 629a5e0..0000000 --- a/packages/diagram-client/src/agent-ui/tool-template.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * The annotation wizard's `@tool` template: the command, its argument list and - * whether it inherits stdio, seeded from the definition's `@tool` if it has one. - */ -import type { AnnotationTemplate, AnnotationTemplateContext } from '../annotation-template'; -import { VscodeUi } from '../vscode-ui'; -import { stripQuotesIfStringLiteral, toWfStringLiteral } from './literals'; - -export class ToolAnnotationTemplate implements AnnotationTemplate { - readonly annotationName = 'tool'; - readonly label = '@tool (external execution)'; - readonly description = 'cmd, args, inheritStdio, ...'; - - async compose(context: AnnotationTemplateContext): Promise { - // Seed from the definition's @tool, if it has one. - const existing = context.existing; - const existingArgs = new Map((existing?.arguments ?? []).map(a => [a.name, a.value])); - - const cmdRaw = await VscodeUi.instance.inputBox({ - prompt: 'tool.cmd (program to run)', - value: existingArgs.has('cmd') ? stripQuotesIfStringLiteral(existingArgs.get('cmd')!) : 'bash' - }); - if (cmdRaw === undefined) return; - - const argsExpr = await VscodeUi.instance.inputBox({ - prompt: 'tool.args (wf-lang expression, e.g. ["-lc", "..."])', - value: existingArgs.get('args') ?? '["-lc", "{in.exe} {in.input} > {out.stdout}"]' - }); - if (argsExpr === undefined) return; - - const inherit = await VscodeUi.instance.quickPick({ - items: [ - { id: 'true', label: 'inheritStdio=true' }, - { id: 'false', label: 'inheritStdio=false' } - ], - placeHolder: 'tool.inheritStdio' - }); - if (!inherit) return; - - const cmdExpr = toWfStringLiteral(cmdRaw); - - return `@tool(\n cmd=${cmdExpr},\n args=${argsExpr.trim()},\n inheritStdio=${inherit}\n)`; - } -} diff --git a/packages/diagram-client/src/agent-ui/viewer-template.ts b/packages/diagram-client/src/agent-ui/viewer-template.ts deleted file mode 100644 index 6b07495..0000000 --- a/packages/diagram-client/src/agent-ui/viewer-template.ts +++ /dev/null @@ -1,108 +0,0 @@ -/** - * The annotation wizard's `@viewer` template: open a task's last token, diff two, - * or open one with a chosen editor, which it can remember in the settings. - */ -import type { AnnotationTemplate } from '../annotation-template'; -import { settingsNamespace } from '../profile'; -import { VscodeUi } from '../vscode-ui'; -import { toWfStringLiteral } from './literals'; - -export class ViewerAnnotationTemplate implements AnnotationTemplate { - readonly annotationName = 'viewer'; - readonly label = '@viewer (open outputs in VS Code)'; - readonly description = 'open, diff, openWith (viewType)'; - - async compose(): Promise { - let annotationText = ''; - - const action = await VscodeUi.instance.quickPick({ - items: [ - { id: 'open', label: 'Open (single file)', description: 'Open last token using default editor' }, - { id: 'diff', label: 'Diff (two files)', description: 'Compare last tokens in VS Code diff editor' }, - { id: 'openWith', label: 'Open With…', description: 'Open using a specific VS Code editor viewType' } - ], - placeHolder: '@viewer.action' - }); - if (!action) return; - - const defaultInputsExpr = action === 'diff' ? '["left", "right"]' : '["In"]'; - const inputsExpr = await VscodeUi.instance.inputBox({ - prompt: 'viewer.inputs (wf-lang expression, e.g. ["In"] or ["left","right"])', - value: defaultInputsExpr - }); - if (inputsExpr === undefined) return; - - let viewType: string | undefined; - if (action === 'openWith') { - const editors = await VscodeUi.instance.getViewerEditors(); - const picked = await VscodeUi.instance.quickPick({ - items: [ - ...editors.map(e => ({ - id: e.viewType, - label: e.label, - description: e.description - })), - { id: '__custom__', label: 'Custom viewType…', description: 'Enter any VS Code editor viewType string' } - ], - placeHolder: 'Choose an editor (viewType)' - }); - if (!picked) return; - - if (picked === '__custom__') { - const customViewType = await VscodeUi.instance.inputBox({ - prompt: 'VS Code editor viewType (for vscode.openWith)', - value: '' - }); - if (customViewType === undefined) return; - const trimmed = customViewType.trim(); - if (!trimmed) return; - - viewType = trimmed; - - const add = await VscodeUi.instance.quickPick({ - items: [ - { id: 'yes', label: 'Add this editor to settings', description: `Append to ${settingsNamespace()}.viewerEditors` }, - { id: 'no', label: 'Do not add' } - ], - placeHolder: 'Remember this editor?' - }); - if (add === 'yes') { - const label = await VscodeUi.instance.inputBox({ - prompt: 'Display label for this editor', - value: trimmed - }); - if (label !== undefined) { - await VscodeUi.instance.appendViewerEditor({ - label: label.trim() || trimmed, - viewType: trimmed - }); - } - } - } else { - viewType = picked; - } - - // Special-case: "default" means normal open (not openWith). - if (viewType === 'default') { - annotationText = `@viewer(\n action="open",\n inputs=${inputsExpr.trim()},\n token="last"\n)`; - } else { - annotationText = `@viewer(\n action="openWith",\n inputs=${inputsExpr.trim()},\n token="last",\n viewType=${toWfStringLiteral(viewType)}\n)`; - } - } - - if (action === 'open') { - annotationText = `@viewer(\n action="open",\n inputs=${inputsExpr.trim()},\n token="last"\n)`; - } - - if (action === 'diff') { - const title = await VscodeUi.instance.inputBox({ - prompt: 'viewer.title (optional; shown in the diff editor tab)', - value: 'Workflow Diff' - }); - if (title === undefined) return; - annotationText = `@viewer(\n action="diff",\n inputs=${inputsExpr.trim()},\n token="last",\n title=${toWfStringLiteral(title)}\n)`; - } - - return annotationText; - } -} diff --git a/packages/diagram-client/src/editing-action-handlers.ts b/packages/diagram-client/src/editing-action-handlers.ts index e7a1d83..2cf1e16 100644 --- a/packages/diagram-client/src/editing-action-handlers.ts +++ b/packages/diagram-client/src/editing-action-handlers.ts @@ -11,30 +11,6 @@ import { FocusAfterLoadService } from './focus-after-load'; import { EXECUTION_OVERLAY_ACTION_KIND, type ExecutionOverlayActionPayload, unqualifiedName } from '@dialogram/shared'; import { showExecutionStates } from './execution-states'; -export type WorkflowAgentSkill = { - name: string; - description?: string; - root: string; - skillDir: string; - skillMdPath: string; - hasSkillMd: boolean; - hooks: string[]; - declaredHooks?: { - pre?: string; - post?: string; - }; - missingDeclaredHooks?: string[]; - warnings?: string[]; -}; - -export type WorkflowClaudeAgentProfile = { - name: string; - description?: string; - path: string; - ignoredFields?: string[]; - warnings?: string[]; -}; - export namespace WorkflowPromptLabelEditAction { export const KIND = 'dialogram.promptLabelEdit'; @@ -255,63 +231,6 @@ export namespace WorkflowUpdateEdgeCapacityOperation { } } -export namespace WorkflowRequestAgentSkillsOperation { - export const KIND = 'dialogram.requestAgentSkills'; - - export type RootsMode = 'all' | 'project'; - - export interface Operation extends GlspAction { - kind: typeof KIND; - elementId: string; - rootsMode?: RootsMode; - } - - export function create(options: Omit): Operation { - return { kind: KIND, ...options }; - } -} - -export namespace WorkflowShowAgentSkillsAction { - export const KIND = 'dialogram.showAgentSkills'; - - export interface Action extends GlspAction { - kind: typeof KIND; - elementId: string; - skills: WorkflowAgentSkill[]; - } - - export function is(action: unknown): action is Action { - return !!action && typeof action === 'object' && (action as any).kind === KIND; - } -} - -export namespace WorkflowRequestClaudeAgentsOperation { - export const KIND = 'dialogram.requestClaudeAgents'; - - export interface Operation extends GlspAction { - kind: typeof KIND; - elementId: string; - } - - export function create(options: Omit): Operation { - return { kind: KIND, ...options }; - } -} - -export namespace WorkflowShowClaudeAgentsAction { - export const KIND = 'dialogram.showClaudeAgents'; - - export interface Action extends GlspAction { - kind: typeof KIND; - elementId: string; - agents: WorkflowClaudeAgentProfile[]; - } - - export function is(action: unknown): action is Action { - return !!action && typeof action === 'object' && (action as any).kind === KIND; - } -} - export namespace WorkflowCreateBoundaryPortOperation { export const KIND = 'dialogram.createBoundaryPort'; @@ -656,73 +575,12 @@ export class WorkflowEditAnnotationsActionHandler implements IActionHandler { } } -@injectable() -export class WorkflowShowAgentSkillsActionHandler implements IActionHandler { - private static readonly skillsByElementId = new Map(); - - static getSkillsForElement(elementId: string): WorkflowAgentSkill[] { - return this.skillsByElementId.get(elementId) ?? []; - } - - /** Skills from the model's snapshot, for a node discovery has not answered for yet. */ - static seed(elementId: string, skills: WorkflowAgentSkill[]): void { - if (!this.skillsByElementId.has(elementId)) { - this.skillsByElementId.set(elementId, skills); - } - } - - handle(action: GlspAction): ICommand | GlspAction | void { - if (!WorkflowShowAgentSkillsAction.is(action)) { - return; - } - const elementId = String(action.elementId ?? '').trim(); - if (!elementId) { - return; - } - const skills = Array.isArray(action.skills) ? action.skills : []; - WorkflowShowAgentSkillsActionHandler.skillsByElementId.set(elementId, skills); - if (typeof window !== 'undefined' && typeof window.dispatchEvent === 'function') { - window.dispatchEvent(new CustomEvent('dialogram.agentSkills.updated', { - detail: { elementId, count: skills.length } - })); - } - } -} - -@injectable() -export class WorkflowShowClaudeAgentsActionHandler implements IActionHandler { - private static readonly agentsByElementId = new Map(); - - static getAgentsForElement(elementId: string): WorkflowClaudeAgentProfile[] { - return this.agentsByElementId.get(elementId) ?? []; - } - - /** Agent presets from the model's snapshot, for a node discovery has not answered for yet. */ - static seed(elementId: string, agents: WorkflowClaudeAgentProfile[]): void { - if (!this.agentsByElementId.has(elementId)) { - this.agentsByElementId.set(elementId, agents); - } - } - - handle(action: GlspAction): ICommand | GlspAction | void { - if (!WorkflowShowClaudeAgentsAction.is(action)) { - return; - } - const elementId = String(action.elementId ?? '').trim(); - if (!elementId) { - return; - } - const agents = Array.isArray(action.agents) ? action.agents : []; - WorkflowShowClaudeAgentsActionHandler.agentsByElementId.set(elementId, agents); - } -} - // ── Live run agent stream (read-only observation of a running workflow) ────── // A run streams agent message deltas over SSE; the host forwards them to the // diagram client as batched events under the neutral EXECUTION_OVERLAY_ACTION_KIND // (the execution-overlay seam's opaque events channel). This handler folds the // events into per-instance live state and notifies the read-only UI via a window -// event (same decoupling pattern as WorkflowShowAgentSkillsAction). +// event. export interface RunStreamEvent { seq?: number; type?: string; diff --git a/packages/diagram-client/src/stock-features.module.ts b/packages/diagram-client/src/stock-features.module.ts index 162f52c..812556b 100644 --- a/packages/diagram-client/src/stock-features.module.ts +++ b/packages/diagram-client/src/stock-features.module.ts @@ -69,8 +69,7 @@ export const workflowFeaturesModule = new ContainerModule((bind, unbind, isBound bind(TYPES.ISelectionListener).toService(PropertyPanel); bind(TYPES.IGModelRootListener).toService(PropertyPanel); // No annotation editors, node sections or wizard templates of its own: a - // product binds those in its feature module. The agent workflow UI is - // `agentUiModule` (agent-ui/), which a product's own bundle loads. + // product binds those in its own bundle's feature module. // The hierarchy outline, fed by the root model's `wf:hierarchy`. Shown only // when the product navigates nested workflows in place and the model diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-context.dom.txt b/packages/diagram-client/test/agent-ui/__snapshots__/agent-context.dom.txt deleted file mode 100644 index 9cdba10..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-context.dom.txt +++ /dev/null @@ -1,101 +0,0 @@ -div .property-section.agent-context-live-section - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Agent Context (4/50)" - div .property-section-content - div .agent-ctx-badges - span .agent-ctx-badge - span .codicon.codicon-flame - "3 firings" - span .agent-ctx-badge - span .codicon.codicon-hubot - "opus" - span .agent-ctx-badge - span .codicon.codicon-fold - "sliding" - span .agent-ctx-badge - span .codicon.codicon-graph - "4/50 msgs" - div .agent-chat-container - details .agent-chat-card.agent-chat-system - summary .agent-chat-header - span .agent-chat-icon.codicon.codicon-settings-gear - span .agent-chat-label - "System Prompt" - span .agent-chat-expand-arrow.codicon.codicon-chevron-right - div .agent-chat-body - div .agent-chat-body-content - div .agent-chat-text - "You are the analyst." - div .agent-chat-card.agent-chat-user - div .agent-chat-header - span .agent-chat-icon.codicon.codicon-arrow-right - span .agent-chat-label - "Input" - div .agent-chat-body - div .agent-chat-body-content - div .agent-json-meta - span .agent-json-tag - "task" - span .agent-json-val - "Analyst" - span .agent-json-tag - "instance" - span .agent-json-val - "analyst" - div .agent-json-section - div .agent-json-section-title - "Inputs" - div .agent-json-kv - span .agent-json-key - "round" - span .agent-json-val title="/w/out/r3/round.json" - "…/r3/round.json" - div .agent-json-file - button .agent-json-file-header - span .agent-json-file-icon.codicon.codicon-file - span .agent-json-key - "round" - span .agent-json-file-name - "…/r3/round.json" - span .agent-chat-expand-arrow.codicon.codicon-chevron-right - div .agent-json-file-content style="display: none;" - "{"round": 3, "cycles": {"dhrystone": 41210}}" - div .agent-chat-card.agent-chat-assistant - div .agent-chat-header - span .agent-chat-icon.codicon.codicon-comment - span .agent-chat-label - "Response" - div .agent-chat-body - details .agent-thinking-section - summary .agent-thinking-header - span .agent-thinking-icon.codicon.codicon-lightbulb - span - "Chain of Thought" - span .agent-chat-expand-arrow.codicon.codicon-chevron-right - div .agent-thinking-body - div .agent-chat-text - "Compare with round 2." - div .agent-chat-body-content - div .agent-md-content - "Budget" - ul - li - "dhrystone:" - strong - "12%" - "of the path" - li - "fetch still on the path" - div .agent-chat-card.agent-chat-assistant - div .agent-chat-header - span .agent-chat-icon.codicon.codicon-comment - span .agent-chat-label - "Response" - div .agent-chat-body - div .agent-chat-body-content - div .agent-chat-text.agent-chat-collapsible - "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx…" - button .agent-chat-toggle - "Show more" diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-node.dom.txt b/packages/diagram-client/test/agent-ui/__snapshots__/agent-node.dom.txt deleted file mode 100644 index 6766715..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-node.dom.txt +++ /dev/null @@ -1,396 +0,0 @@ -div - div .property-section - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Basic Info" - div .property-section-content - div .pp-arow - span .pp-akey - "Name" - span .pp-aval - "analyst" - div .pp-arow - span .pp-akey - "Type" - span .pp-aval - span - "External Task" - code .pp-chip title="Analyst" - "Analyst" - div .property-section - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Annotations (1)" - div .section-actions - button .mini-btn type="button" title="Add or update @tool annotation" - "Add @tool" - button .mini-btn type="button" title="Add or update @agent annotation" - "Add @agent" - button .mini-btn type="button" title="Open the annotation wizard" - "Wizard…" - div .property-section-content - div .annotation-list - div .annotation-item - div .annotation-header - div .annotation-title - "@agent" - div .annotation-actions - button .mini-btn type="button" title="Save changes to the referenced definition" - "Save" - button .mini-btn.danger type="button" title="Remove this annotation" - "Remove" - div .annotation-body - div .agent-skill-block.agent-prompt-block - div .agent-prompt-header - div .agent-prompt-label - "Prompt" - span .codicon.codicon-info title="Use {in.portName} or {out.portName} to reference port data" style="cursor:help;opacity:0.6;font-size:12px" - div style="display: flex; gap: 8px;" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" checked - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content style="position: relative;" - div .agent-skill-actions - button .mini-btn style="display: flex; gap: 4px; align-items: center;" - span .codicon.codicon-screen-full - "Expand Editor" - div style="position: absolute; display: none; background-color: var(--vscode-editorSuggestWidget-background, #252526); border: 1px solid; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.4); z-index: 999999; max-height: 150px; overflow-y: auto; min-width: 150px; font-size: 12px; border-width: var(--vscode-editorSuggestWidget-border, #454545); border-style: var(--vscode-editorSuggestWidget-border, #454545); border-color: var(--vscode-editorSuggestWidget-border, #454545);" - textarea .annotation-input.annotation-textarea.agent-prompt-input placeholder="Transform inputs into outputs...\nWait for { to trigger autocomplete!" value="You are the analyst of a hardware design loop.\nSay, per program, the budget in cycles." - div .agent-skill-block.disabled - div .agent-section-head - div .agent-skill-label - "Agent Preset" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content style="display: none;" - input .annotation-input placeholder="e.g. code-reviewer" disabled - div .agent-skill-hint - "Resolved from .claude/agents/.md" - div .agent-skill-actions - button .mini-btn type="button" disabled - "Pick Preset" - button .mini-btn type="button" disabled - "Open Preset" - div .agent-skill-status - "Agent preset is deactivated." - div .agent-skill-chips style="display: none;" - div .agent-skill-warning style="display: none;" - div .agent-skill-block - div .agent-section-head - div .agent-skill-label - "Skill" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" checked - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content - input .annotation-input placeholder="e.g. writer" style="width: 100%; box-sizing: border-box;" - div .agent-skill-hint - "Resolved at runtime from .wf/skills//SKILL.md" - div .agent-skill-actions - button .mini-btn type="button" - "Pick Skill" - button .mini-btn type="button" - "Refresh" - button .mini-btn type="button" - "Open Skill" - label .agent-toggle - input type="checkbox" value="on" checked - span - "Enable Skill Hooks" - div .agent-skill-status - "No skill selected." - div .agent-skill-chips style="display: none;" - div .agent-skill-warning style="display: none;" - details .agent-skill-block.agent-collapsible.disabled - summary .agent-section-head - span .agent-collapsible-title - span .codicon.codicon-chevron-right.agent-collapse-chevron - div .agent-skill-label - "MCP" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-bottom: 4px;" - "Servers" - input .annotation-input placeholder="e.g. filesystem, github" disabled style="width: 100%; box-sizing: border-box;" - div .agent-skill-hint - "Comma-separated MCP server names from agent-tools.json registry" - div style="margin-top: 8px;" - button style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add MCP Server" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Transport" - select .annotation-input value="bridge" disabled style="width: 100%; box-sizing: border-box;" - option value="bridge" selected - "bridge" - option value="stdio" - "stdio" - option value="http" - "http" - option value="streamable-http" - "streamable-http" - details .agent-skill-block.agent-collapsible.disabled - summary .agent-section-head - span .agent-collapsible-title - span .codicon.codicon-chevron-right.agent-collapse-chevron - div .agent-skill-label - "LSP" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-bottom: 4px;" - "Servers" - input .annotation-input placeholder="e.g. pylance, typescript" disabled style="width: 100%; box-sizing: border-box;" - div .agent-skill-hint - "Comma-separated LSP server names available to this agent" - div style="margin-top: 8px;" - button style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add LSP Server" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Command" - input .annotation-input placeholder="e.g. pylsp" disabled style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Args" - input .annotation-input placeholder="space-separated" style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Language" - input .annotation-input placeholder="e.g. cpp" style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Extra Flags" - input .annotation-input placeholder="space-separated" style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Severity" - select .annotation-input value="error" style="width: 100%; box-sizing: border-box;" - option value="error" selected - "error" - option value="warning" - "warning" - option value="information" - "information" - option value="hint" - "hint" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Max Repairs" - input .annotation-input type="number" placeholder="2" style="width: 100%; box-sizing: border-box;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Port" - input .annotation-input type="number" placeholder="e.g. 2087" disabled style="width: 100%; box-sizing: border-box;" - details .agent-skill-block.agent-collapsible - summary .agent-section-head - span .agent-collapsible-title - span .codicon.codicon-chevron-right.agent-collapse-chevron - span .agent-skill-label - "Output Validators" - span .agent-collapsible-count - div .agent-section-content - div .agent-skill-hint - "Validate agent output by running a command or an LSP server; failures re-prompt the agent." - div style="margin-top: 4px;" - button style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add Output Validator" - div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Model" - input .annotation-input placeholder="e.g. gpt-4" value="opus" style="width: 100%; box-sizing: border-box;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Provider" - input .annotation-input placeholder="e.g. openai" style="width: 100%; box-sizing: border-box;" - div style="margin-bottom: 12px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-bottom: 4px;" - "Endpoint" - input .annotation-input placeholder="https://api.openai.com/v1" style="width: 100%; box-sizing: border-box;" - div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; padding-top: 4px; border-top-width: var(--vscode-editorWidget-border); border-top-style: var(--vscode-editorWidget-border); border-top-color: var(--vscode-editorWidget-border);" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8;" - "Timeout (ms)" - input .annotation-input type="number" placeholder="30000" value="1800000" style="width: 100%; box-sizing: border-box;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8;" - "Context Budget" - input .annotation-input type="number" placeholder="20" value="50" style="width: 100%; box-sizing: border-box;" - div style="display: flex; flex-direction: column; gap: 4px; align-items: center;" - div style="font-size: 10px; opacity: 0.8;" - "Stateful" - label style="display: inline-block; position: relative; width: 32px; height: 18px; margin-bottom: 3px;" - input type="checkbox" value="on" style="opacity: 0; width: 0px; height: 0px;" - span style="position: absolute; cursor: pointer; top: 0px; left: 0px; right: 0px; bottom: 0px; background-color: var(--vscode-input-background); border: 1px solid; transition: .2s; border-radius: 18px; border-width: var(--vscode-input-border); border-style: var(--vscode-input-border); border-color: var(--vscode-input-border);" - span style="position: absolute; content: \"\"; height: 12px; width: 12px; left: 3px; bottom: 2px; background-color: var(--vscode-foreground); transition: .2s; border-radius: 50%; opacity: 0.6;" - div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Truncation" - select .annotation-input value="sliding" style="width: 100%; box-sizing: border-box;" - option value="sliding" selected - "sliding" - option value="summarize" - "summarize" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Fireable w/o input" - input .annotation-input type="number" placeholder="0" style="width: 100%; box-sizing: border-box;" - div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Transport" - select .annotation-input value="claude-cli" style="width: 100%; box-sizing: border-box;" - optgroup - option value="http" - "http" - optgroup - option value="acp" - "acp (connector)" - option value="opencode-acp" - "opencode-acp" - optgroup - option value="opencode-cli" - "opencode-cli" - option value="claude-cli" selected - "claude-cli" - option value="codex-cli" - "codex-cli" - div title="Applies to the ACP transports" style="display: flex; flex-direction: column; gap: 4px; opacity: 0.45;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Connector" - select .annotation-input disabled style="width: 100%; box-sizing: border-box;" - option value selected - "(run's default)" - optgroup - option value="claude-code" - "claude-code" - option value="opencode" - "opencode (not on PATH)" - div style="display: flex; flex-direction: column; gap: 4px; opacity: 0.45;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Session mode" - input .annotation-input placeholder="agent's default" disabled style="width: 100%; box-sizing: border-box;" - div title style="display: flex; flex-direction: column; gap: 4px; opacity: 1;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "CLI Tools" - select .annotation-input value="native" style="width: 100%; box-sizing: border-box;" - option value="wfpy-none" - "wfpy-none" - option value="native" selected - "native" - div title style="display: flex; flex-direction: column; gap: 4px; opacity: 1;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Reasoning" - select .annotation-input style="width: 100%; box-sizing: border-box;" - option value selected - "default" - option value="minimal" - "minimal" - option value="high" - "high" - option value="max" - "max" - div - div .annotation-add-row - input .annotation-input.annotation-add-key placeholder="argName" value="askUser" - input .annotation-input.annotation-add-value placeholder="value (expression)" value="true" - button .mini-btn.icon-only.danger type="button" title="Remove this argument" aria-label="Remove this argument" - span .codicon.codicon-close - button type="button" style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 8px; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add Argument" - div .property-section - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Agent Tools" - div .property-section-content - div style="display: flex; align-items: center; justify-content: space-between;" - div style="display: flex; align-items: center; gap: 8px;" - span .property-label - "Enabled" - label style="display: inline-block; position: relative; width: 32px; height: 18px; cursor: pointer;" - input type="checkbox" value="on" style="opacity: 0; width: 0px; height: 0px;" - span style="position: absolute; top: 0px; left: 0px; right: 0px; bottom: 0px; background-color: var(--vscode-input-background); border: 1px solid; transition: .2s; border-radius: 18px; border-width: var(--vscode-input-border); border-style: var(--vscode-input-border); border-color: var(--vscode-input-border);" - span style="position: absolute; height: 12px; width: 12px; left: 3px; bottom: 2px; background-color: var(--vscode-foreground); transition: .2s; border-radius: 50%; opacity: 0.6; transform: translateX(0);" - div style="display: flex; align-items: center; gap: 6px;" - span style="font-size: 11px; opacity: 0.5;" - "Auth Mode" - select .annotation-input value="allow-all" disabled style="width: 100px; padding: 2px 4px; height: 22px; opacity: 0.5;" - option value="deny-all" - "deny-all" - option value="allow-all" selected - "allow-all" - option value="policy" - "policy" - div .property-section.collapsed - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Definition Parameters (0)" - div .property-section-content - div .pp-defparam-list - div .param-def-empty - "No parameters on the referenced definition." - button .pp-add-param-btn type="button" - span .codicon.codicon-add - "Add parameter" - div .pp-add-param-form [hidden] - input .pp-defparam-name placeholder="name" aria-label="new parameter name" - input .pp-defparam-type placeholder="type (optional)" aria-label="new parameter type" - input .pp-defparam-default placeholder="default (optional)" aria-label="new parameter default" - div .pp-add-param-actions - button .mini-btn type="button" - "Cancel" - button .mini-btn.primary type="button" - "Add" - div .property-section.collapsed - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Input Ports (0)" - div .section-actions - button .pp-icon-btn title="Add input port" - span .codicon.codicon-add - div .property-section-content - div .pp-empty - "No" - "input" - "ports." - div .property-section.collapsed - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Output Ports (0)" - div .section-actions - button .pp-icon-btn title="Add output port" - span .codicon.codicon-add - div .property-section-content - div .pp-empty - "No" - "output" - "ports." diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset-picked.dom.txt b/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset-picked.dom.txt deleted file mode 100644 index 2ec683c..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset-picked.dom.txt +++ /dev/null @@ -1,26 +0,0 @@ -div .agent-skill-block - div .agent-section-head - div .agent-skill-label - "Agent Preset" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" checked - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content - input .annotation-input placeholder="e.g. code-reviewer" value="code-reviewer" - div .agent-skill-hint - "Resolved from .claude/agents/.md" - div .agent-skill-actions - button .mini-btn type="button" - "Pick Preset" - button .mini-btn type="button" - "Open Preset" - div .agent-skill-status - "Reviews diffs" - div .agent-skill-chips style="display: flex;" - span .agent-chip.warn - "IGNORED tools" - div .agent-skill-warning style="display: none;" diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset-save.arg-updates.json b/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset-save.arg-updates.json deleted file mode 100644 index 65d308c..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset-save.arg-updates.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "prompt": "\"You are the analyst of a hardware design loop.\\nSay, per program, the budget in cycles.\"", - "model": "\"opus\"", - "timeoutMs": "1800000", - "contextBudget": "50", - "useClaudeAgent": "true", - "useSkill": "true", - "usePrompt": "true", - "stateful": "false", - "truncationStrategy": "\"sliding\"", - "transport": "\"claude-cli\"", - "reasoningEffort": "\"None\"", - "askUser": "true", - "cliToolsMode": "\"native\"", - "useSkillHooks": "true", - "claudeAgent": "\"code-reviewer\"" -} diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset.pick.json b/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset.pick.json deleted file mode 100644 index a335a09..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-preset.pick.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "items": [ - { - "id": "code-reviewer", - "label": "code-reviewer", - "description": "Reviews diffs", - "detail": "/w/.claude/agents/code-reviewer.md" - } - ], - "placeHolder": "Select an agent preset" -} diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-save.annotation.txt b/packages/diagram-client/test/agent-ui/__snapshots__/agent-save.annotation.txt deleted file mode 100644 index a372852..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-save.annotation.txt +++ /dev/null @@ -1,16 +0,0 @@ -@agent( - prompt="You are the analyst of a hardware design loop.\nSay, per program, the budget in cycles.", - model="opus", - timeoutMs=1800000, - contextBudget=50, - stateful=false, - useClaudeAgent=false, - useSkill=true, - usePrompt=true, - useSkillHooks=true, - truncationStrategy="sliding", - transport="claude-cli", - cliToolsMode="native", - reasoningEffort="None", - askUser=true -) diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-save.arg-updates.json b/packages/diagram-client/test/agent-ui/__snapshots__/agent-save.arg-updates.json deleted file mode 100644 index f5fd5a1..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-save.arg-updates.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "prompt": "\"You are the analyst of a hardware design loop.\\nSay, per program, the budget in cycles.\"", - "model": "\"opus\"", - "timeoutMs": "1800000", - "contextBudget": "50", - "useClaudeAgent": "false", - "useSkill": "true", - "usePrompt": "true", - "stateful": "false", - "truncationStrategy": "\"sliding\"", - "transport": "\"claude-cli\"", - "reasoningEffort": "\"None\"", - "askUser": "true", - "cliToolsMode": "\"native\"", - "useSkillHooks": "true" -} diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-skill-picked.dom.txt b/packages/diagram-client/test/agent-ui/__snapshots__/agent-skill-picked.dom.txt deleted file mode 100644 index bd1ccfc..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-skill-picked.dom.txt +++ /dev/null @@ -1,295 +0,0 @@ -div .annotation-item - div .annotation-header - div .annotation-title - "@agent" - div .annotation-actions - button .mini-btn type="button" title="Save changes to the referenced definition" - "Save" - button .mini-btn.danger type="button" title="Remove this annotation" - "Remove" - div .annotation-body - div .agent-skill-block.agent-prompt-block - div .agent-prompt-header - div .agent-prompt-label - "Prompt" - span .codicon.codicon-info title="Use {in.portName} or {out.portName} to reference port data" style="cursor:help;opacity:0.6;font-size:12px" - div style="display: flex; gap: 8px;" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" checked - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content style="position: relative;" - div .agent-skill-actions - button .mini-btn style="display: flex; gap: 4px; align-items: center;" - span .codicon.codicon-screen-full - "Expand Editor" - div style="position: absolute; display: none; background-color: var(--vscode-editorSuggestWidget-background, #252526); border: 1px solid; border-radius: 4px; box-shadow: 0 4px 8px rgba(0,0,0,0.4); z-index: 999999; max-height: 150px; overflow-y: auto; min-width: 150px; font-size: 12px; border-width: var(--vscode-editorSuggestWidget-border, #454545); border-style: var(--vscode-editorSuggestWidget-border, #454545); border-color: var(--vscode-editorSuggestWidget-border, #454545);" - textarea .annotation-input.annotation-textarea.agent-prompt-input placeholder="Transform inputs into outputs...\nWait for { to trigger autocomplete!" value="You are the analyst of a hardware design loop.\nSay, per program, the budget in cycles." - div .agent-skill-block.disabled - div .agent-section-head - div .agent-skill-label - "Agent Preset" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content style="display: none;" - input .annotation-input placeholder="e.g. code-reviewer" disabled - div .agent-skill-hint - "Resolved from .claude/agents/.md" - div .agent-skill-actions - button .mini-btn type="button" disabled - "Pick Preset" - button .mini-btn type="button" disabled - "Open Preset" - div .agent-skill-status - "Agent preset is deactivated." - div .agent-skill-chips style="display: none;" - div .agent-skill-warning style="display: none;" - div .agent-skill-block - div .agent-section-head - div .agent-skill-label - "Skill" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" checked - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content - input .annotation-input placeholder="e.g. writer" value="turnus-reader" style="width: 100%; box-sizing: border-box;" - div .agent-skill-hint - "Resolved at runtime from .wf/skills//SKILL.md" - div .agent-skill-actions - button .mini-btn type="button" - "Pick Skill" - button .mini-btn type="button" - "Refresh" - button .mini-btn type="button" - "Open Skill" - label .agent-toggle - input type="checkbox" value="on" checked - span - "Enable Skill Hooks" - div .agent-skill-status - "Reads Turnus analyses" - div .agent-skill-chips style="display: flex;" - span .agent-chip.info - "INFO 1 script hook" - span .agent-chip.ok - "OK Skill ready" - div .agent-skill-warning style="display: none;" - details .agent-skill-block.agent-collapsible.disabled - summary .agent-section-head - span .agent-collapsible-title - span .codicon.codicon-chevron-right.agent-collapse-chevron - div .agent-skill-label - "MCP" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-bottom: 4px;" - "Servers" - input .annotation-input placeholder="e.g. filesystem, github" disabled style="width: 100%; box-sizing: border-box;" - div .agent-skill-hint - "Comma-separated MCP server names from agent-tools.json registry" - div style="margin-top: 8px;" - button style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add MCP Server" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Transport" - select .annotation-input value="bridge" disabled style="width: 100%; box-sizing: border-box;" - option value="bridge" selected - "bridge" - option value="stdio" - "stdio" - option value="http" - "http" - option value="streamable-http" - "streamable-http" - details .agent-skill-block.agent-collapsible.disabled - summary .agent-section-head - span .agent-collapsible-title - span .codicon.codicon-chevron-right.agent-collapse-chevron - div .agent-skill-label - "LSP" - label .agent-enabled-toggle - span .agent-enabled-text - "Enabled" - span .agent-enabled-switch - input .agent-enabled-input type="checkbox" value="on" - span .agent-enabled-slider - span .agent-enabled-knob - div .agent-section-content - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-bottom: 4px;" - "Servers" - input .annotation-input placeholder="e.g. pylance, typescript" disabled style="width: 100%; box-sizing: border-box;" - div .agent-skill-hint - "Comma-separated LSP server names available to this agent" - div style="margin-top: 8px;" - button style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add LSP Server" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Command" - input .annotation-input placeholder="e.g. pylsp" disabled style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Args" - input .annotation-input placeholder="space-separated" style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Language" - input .annotation-input placeholder="e.g. cpp" style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Extra Flags" - input .annotation-input placeholder="space-separated" style="width: 100%; box-sizing: border-box;" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Severity" - select .annotation-input value="error" style="width: 100%; box-sizing: border-box;" - option value="error" selected - "error" - option value="warning" - "warning" - option value="information" - "information" - option value="hint" - "hint" - div - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Max Repairs" - input .annotation-input type="number" placeholder="2" style="width: 100%; box-sizing: border-box;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-top: 8px; margin-bottom: 4px;" - "Port" - input .annotation-input type="number" placeholder="e.g. 2087" disabled style="width: 100%; box-sizing: border-box;" - details .agent-skill-block.agent-collapsible - summary .agent-section-head - span .agent-collapsible-title - span .codicon.codicon-chevron-right.agent-collapse-chevron - span .agent-skill-label - "Output Validators" - span .agent-collapsible-count - div .agent-section-content - div .agent-skill-hint - "Validate agent output by running a command or an LSP server; failures re-prompt the agent." - div style="margin-top: 4px;" - button style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add Output Validator" - div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Model" - input .annotation-input placeholder="e.g. gpt-4" value="opus" style="width: 100%; box-sizing: border-box;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Provider" - input .annotation-input placeholder="e.g. openai" style="width: 100%; box-sizing: border-box;" - div style="margin-bottom: 12px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500; margin-bottom: 4px;" - "Endpoint" - input .annotation-input placeholder="https://api.openai.com/v1" style="width: 100%; box-sizing: border-box;" - div style="display: grid; grid-template-columns: 1fr 1fr auto; gap: 8px; align-items: end; padding-top: 4px; border-top-width: var(--vscode-editorWidget-border); border-top-style: var(--vscode-editorWidget-border); border-top-color: var(--vscode-editorWidget-border);" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8;" - "Timeout (ms)" - input .annotation-input type="number" placeholder="30000" value="1800000" style="width: 100%; box-sizing: border-box;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8;" - "Context Budget" - input .annotation-input type="number" placeholder="20" value="50" style="width: 100%; box-sizing: border-box;" - div style="display: flex; flex-direction: column; gap: 4px; align-items: center;" - div style="font-size: 10px; opacity: 0.8;" - "Stateful" - label style="display: inline-block; position: relative; width: 32px; height: 18px; margin-bottom: 3px;" - input type="checkbox" value="on" style="opacity: 0; width: 0px; height: 0px;" - span style="position: absolute; cursor: pointer; top: 0px; left: 0px; right: 0px; bottom: 0px; background-color: var(--vscode-input-background); border: 1px solid; transition: .2s; border-radius: 18px; border-width: var(--vscode-input-border); border-style: var(--vscode-input-border); border-color: var(--vscode-input-border);" - span style="position: absolute; content: \"\"; height: 12px; width: 12px; left: 3px; bottom: 2px; background-color: var(--vscode-foreground); transition: .2s; border-radius: 50%; opacity: 0.6;" - div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Truncation" - select .annotation-input value="sliding" style="width: 100%; box-sizing: border-box;" - option value="sliding" selected - "sliding" - option value="summarize" - "summarize" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Fireable w/o input" - input .annotation-input type="number" placeholder="0" style="width: 100%; box-sizing: border-box;" - div style="display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px;" - div style="display: flex; flex-direction: column; gap: 4px;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Transport" - select .annotation-input value="claude-cli" style="width: 100%; box-sizing: border-box;" - optgroup - option value="http" - "http" - optgroup - option value="acp" - "acp (connector)" - option value="opencode-acp" - "opencode-acp" - optgroup - option value="opencode-cli" - "opencode-cli" - option value="claude-cli" selected - "claude-cli" - option value="codex-cli" - "codex-cli" - div title="Applies to the ACP transports" style="display: flex; flex-direction: column; gap: 4px; opacity: 0.45;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Connector" - select .annotation-input disabled style="width: 100%; box-sizing: border-box;" - option value selected - "(run's default)" - optgroup - option value="claude-code" - "claude-code" - option value="opencode" - "opencode (not on PATH)" - div style="display: flex; flex-direction: column; gap: 4px; opacity: 0.45;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Session mode" - input .annotation-input placeholder="agent's default" disabled style="width: 100%; box-sizing: border-box;" - div title style="display: flex; flex-direction: column; gap: 4px; opacity: 1;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "CLI Tools" - select .annotation-input value="native" style="width: 100%; box-sizing: border-box;" - option value="wfpy-none" - "wfpy-none" - option value="native" selected - "native" - div title style="display: flex; flex-direction: column; gap: 4px; opacity: 1;" - div style="font-size: 10px; opacity: 0.8; font-weight: 500;" - "Reasoning" - select .annotation-input style="width: 100%; box-sizing: border-box;" - option value selected - "default" - option value="minimal" - "minimal" - option value="high" - "high" - option value="max" - "max" - div - div .annotation-add-row - input .annotation-input.annotation-add-key placeholder="argName" value="askUser" - input .annotation-input.annotation-add-value placeholder="value (expression)" value="true" - button .mini-btn.icon-only.danger type="button" title="Remove this argument" aria-label="Remove this argument" - span .codicon.codicon-close - button type="button" style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 8px; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add Argument" diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-tools.calls.json b/packages/diagram-client/test/agent-ui/__snapshots__/agent-tools.calls.json deleted file mode 100644 index cd0b8bd..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-tools.calls.json +++ /dev/null @@ -1,37 +0,0 @@ -[ - { - "method": "executeCommand", - "args": [ - "wfpy.glsp.getAgentToolConfig", - [ - "tooled" - ] - ] - }, - { - "method": "executeCommand", - "args": [ - "wfpy.glsp.setAgentToolConfig", - [ - { - "entityName": "tooled", - "enabled": true, - "auth": "deny-all" - } - ] - ] - }, - { - "method": "executeCommand", - "args": [ - "wfpy.glsp.setAgentToolConfig", - [ - { - "entityName": "tooled", - "enabled": false, - "auth": "deny-all" - } - ] - ] - } -] diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/agent-tools.dom.txt b/packages/diagram-client/test/agent-ui/__snapshots__/agent-tools.dom.txt deleted file mode 100644 index 583eddf..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/agent-tools.dom.txt +++ /dev/null @@ -1,24 +0,0 @@ -div .property-section - div .property-section-header - span .section-icon.codicon.codicon-chevron-down - span .section-title - "Agent Tools" - div .property-section-content - div style="display: flex; align-items: center; justify-content: space-between;" - div style="display: flex; align-items: center; gap: 8px;" - span .property-label - "Enabled" - label style="display: inline-block; position: relative; width: 32px; height: 18px; cursor: pointer;" - input type="checkbox" value="on" checked style="opacity: 0; width: 0px; height: 0px;" - span style="position: absolute; top: 0px; left: 0px; right: 0px; bottom: 0px; background-color: var(--vscode-button-background); border: 1px solid; transition: .2s; border-radius: 18px; border-width: var(--vscode-input-border); border-style: var(--vscode-input-border); border-color: var(--vscode-input-border);" - span style="position: absolute; height: 12px; width: 12px; left: 3px; bottom: 2px; background-color: var(--vscode-button-foreground); transition: .2s; border-radius: 50%; opacity: 1; transform: translateX(14px);" - div style="display: flex; align-items: center; gap: 6px;" - span style="font-size: 11px; opacity: 0.9;" - "Auth Mode" - select .annotation-input value="policy" style="width: 100px; padding: 2px 4px; height: 22px; opacity: 1;" - option value="deny-all" - "deny-all" - option value="allow-all" - "allow-all" - option value="policy" selected - "policy" diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/tool-card.dom.txt b/packages/diagram-client/test/agent-ui/__snapshots__/tool-card.dom.txt deleted file mode 100644 index 13b9bff..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/tool-card.dom.txt +++ /dev/null @@ -1,54 +0,0 @@ -div .annotation-item - div .annotation-header - div .annotation-title - "@tool" - div .annotation-actions - button .mini-btn type="button" title="Save changes to the referenced definition" - "Save" - button .mini-btn.danger type="button" title="Remove this annotation" - "Remove" - div .annotation-body - div .annotation-field - div .annotation-label - "cmd (string)" - input .annotation-input placeholder="e.g. bash" value="bash" - div .annotation-args-container - div .annotation-args-header - div .annotation-args-title - "args (list)" - div .annotation-args-mode - button .mini-btn type="button" - "List…" - button .mini-btn type="button" - "Raw…" - div .annotation-args-help - "Each row is one CLI argument (string)." - div .annotation-args-list - div .annotation-args-item - div .annotation-args-index - "0" - input .annotation-input.annotation-args-input placeholder="argument" value="-lc" - button .mini-btn.danger.icon-only type="button" title="Remove item" aria-label="Remove item" - span .codicon.codicon-remove - div .annotation-args-item - div .annotation-args-index - "1" - input .annotation-input.annotation-args-input placeholder="argument" value="make all" - button .mini-btn.danger.icon-only type="button" title="Remove item" aria-label="Remove item" - span .codicon.codicon-remove - button .mini-btn type="button" - "Add item" - div .annotation-field style="display: none;" - div .annotation-label - "args (expression)" - textarea .annotation-input.annotation-textarea placeholder="e.g. [\"-lc\", \"{in.exe} {in.input} > {out.stdout}\"]" - div .annotation-field - div .annotation-label - "inheritStdio (bool)" - div .annotation-checkbox-wrap - input .annotation-checkbox type="checkbox" value="on" checked - div .annotation-checkbox-text - "true" - div - button type="button" style="background: none none; border: 1px dashed; color: var(--vscode-foreground); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; width: 100%; margin-top: 8px; border-width: var(--vscode-panel-border, #555); border-style: var(--vscode-panel-border, #555); border-color: var(--vscode-panel-border, #555);" - "+ Add Argument" diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/tool-save.annotation.txt b/packages/diagram-client/test/agent-ui/__snapshots__/tool-save.annotation.txt deleted file mode 100644 index ed62e99..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/tool-save.annotation.txt +++ /dev/null @@ -1,5 +0,0 @@ -@tool( - cmd="bash", - args=["-lc", "make all"], - inherit_stdio=True -) diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/tool-save.arg-updates.json b/packages/diagram-client/test/agent-ui/__snapshots__/tool-save.arg-updates.json deleted file mode 100644 index 8e36547..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/tool-save.arg-updates.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "cmd": "\"bash\"", - "args": "[\"-lc\", \"make all\"]", - "inheritStdio": "true" -} diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/wizard-menus.json b/packages/diagram-client/test/agent-ui/__snapshots__/wizard-menus.json deleted file mode 100644 index 941732e..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/wizard-menus.json +++ /dev/null @@ -1,35 +0,0 @@ -[ - { - "items": [ - { - "id": "upsert", - "label": "Add or Update Annotation…" - }, - { - "id": "remove", - "label": "Remove Annotation…" - } - ], - "placeHolder": "Choose an annotation operation" - }, - { - "items": [ - { - "id": "tool", - "label": "@tool (external execution)", - "description": "cmd, args, inheritStdio, ..." - }, - { - "id": "viewer", - "label": "@viewer (open outputs in VS Code)", - "description": "open, diff, openWith (viewType)" - }, - { - "id": "custom", - "label": "Custom annotation…", - "description": "Enter any name and arguments" - } - ], - "placeHolder": "Choose an annotation template" - } -] diff --git a/packages/diagram-client/test/agent-ui/__snapshots__/wizard-viewer-editors.json b/packages/diagram-client/test/agent-ui/__snapshots__/wizard-viewer-editors.json deleted file mode 100644 index 81813eb..0000000 --- a/packages/diagram-client/test/agent-ui/__snapshots__/wizard-viewer-editors.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "items": [ - { - "id": "hexEditor.hexedit", - "label": "Hex Editor" - }, - { - "id": "__custom__", - "label": "Custom viewType…", - "description": "Enter any VS Code editor viewType string" - } - ], - "placeHolder": "Choose an editor (viewType)" -} diff --git a/packages/diagram-client/test/agent-ui/agent-editor.test.ts b/packages/diagram-client/test/agent-ui/agent-editor.test.ts deleted file mode 100644 index 7d2007f..0000000 --- a/packages/diagram-client/test/agent-ui/agent-editor.test.ts +++ /dev/null @@ -1,183 +0,0 @@ -// @vitest-environment happy-dom -/** - * The `@agent` editor and the annotation section's agent and tool buttons, as - * the property panel draws and sends them today for wfpy's agent nodes. - * Characterization before the move to wfpy-ide: see `harness.ts`. - */ -import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; -import { WorkflowShowAgentSkillsActionHandler, WorkflowShowClaudeAgentsActionHandler } from '../../src/editing-action-handlers'; -import { ui, installPage, taskNode, dumpDom, ANALYST_AGENT } from './harness'; -import { TestPanel, button, card, resetAgentUi, section, tick } from './panel'; - -vi.mock('../../src/vscode-ui', async () => (await import('./harness')).vscodeUiModule()); - -const content = () => document.getElementById('property-content')!; - -beforeEach(() => { - resetAgentUi(); - installPage(); -}); - -afterEach(() => { - delete (globalThis as any).diagramIdentifier; -}); - -describe('an @agent node', () => { - it('draws its annotation editor, its tool settings and its sections as today', async () => { - const panel = new TestPanel(); - panel.show(taskNode('analyst', [ANALYST_AGENT])); - await tick(); - await expect(dumpDom(content())).toMatchFileSnapshot('./__snapshots__/agent-node.dom.txt'); - }); - - it('asks the server for skills and agent presets, and the host for its tool settings', async () => { - const panel = new TestPanel(); - panel.show(taskNode('asker', [ANALYST_AGENT])); - await tick(); - expect(panel.dispatched).toEqual([ - { kind: 'dialogram.requestAgentSkills', elementId: 'node:scope:root:asker', rootsMode: 'all' }, - { kind: 'dialogram.requestClaudeAgents', elementId: 'node:scope:root:asker' } - ]); - expect(ui.calls).toEqual([ - { method: 'executeCommand', args: ['wfpy.glsp.getAgentToolConfig', ['asker']] } - ]); - }); - - it('saves the whole annotation, its arguments in the order it writes them', async () => { - const panel = new TestPanel(); - panel.show(taskNode('saver', [ANALYST_AGENT])); - button(card('agent'), 'Save').click(); - await tick(); - const op = panel.last('dialogram.updateDefinitionAnnotation'); - expect(op).toMatchObject({ isOperation: true, elementId: 'node:scope:root:saver', action: 'upsert', annotationName: 'agent' }); - await expect(op.annotationText + '\n').toMatchFileSnapshot('./__snapshots__/agent-save.annotation.txt'); - await expect(JSON.stringify(op.argUpdates, null, 2) + '\n').toMatchFileSnapshot('./__snapshots__/agent-save.arg-updates.json'); - }); - - it('saves an edited prompt as a quoted string literal', async () => { - const panel = new TestPanel(); - panel.show(taskNode('editor', [ANALYST_AGENT])); - const prompt = card('agent').querySelector('textarea.agent-prompt-input') as HTMLTextAreaElement; - prompt.value = 'Summarize "the" round.\nThen stop.'; - prompt.dispatchEvent(new Event('input', { bubbles: true })); - prompt.dispatchEvent(new Event('change', { bubbles: true })); - button(card('agent'), 'Save').click(); - await tick(); - const op = panel.last('dialogram.updateDefinitionAnnotation'); - expect(op.argUpdates.prompt).toBe('"Summarize \\"the\\" round.\\nThen stop."'); - expect(op.annotationText).toContain('prompt="Summarize \\"the\\" round.\\nThen stop."'); - }); - - it('removes the annotation', async () => { - const panel = new TestPanel(); - panel.show(taskNode('remover', [ANALYST_AGENT])); - button(card('agent'), 'Remove').click(); - await tick(); - expect(panel.last('dialogram.updateDefinitionAnnotation')).toMatchObject({ - elementId: 'node:scope:root:remover', action: 'remove', annotationName: 'agent' - }); - }); -}); - -describe('discovered skills and agent presets', () => { - const SKILLS = [ - { - name: 'turnus-reader', description: 'Reads Turnus analyses', root: '/w/.wf/skills', - skillDir: '/w/.wf/skills/turnus-reader', skillMdPath: '/w/.wf/skills/turnus-reader/SKILL.md', - hasSkillMd: true, hooks: ['pre.py'], declaredHooks: { pre: 'pre.py' } - }, - { - name: 'half-done', root: '/home/u/.claude/skills', skillDir: '/home/u/.claude/skills/half-done', - skillMdPath: '/home/u/.claude/skills/half-done/SKILL.md', hasSkillMd: false, hooks: [], - warnings: ['missing SKILL.md'] - } - ]; - - it('offers the skills the server found, and saves the one picked', async () => { - const node = taskNode('picker', [ANALYST_AGENT]); - const panel = new TestPanel(); - panel.listenForDiscovery(); - onTestFinished(() => panel.dispose()); - panel.show(node); - new WorkflowShowAgentSkillsActionHandler().handle({ kind: 'dialogram.showAgentSkills', elementId: node.id, skills: SKILLS } as any); - await tick(); - - ui.picks.push('turnus-reader'); - button(card('agent'), 'Pick Skill').click(); - await tick(); - const pick = ui.calls.find(c => c.method === 'quickPick')!; - expect(pick.args[0]).toEqual({ - items: [ - { id: 'turnus-reader', label: 'turnus-reader', description: 'Reads Turnus analyses', detail: '/w/.wf/skills' }, - { id: 'half-done', label: 'half-done', description: 'missing SKILL.md', detail: '/home/u/.claude/skills | missing SKILL.md' } - ], - placeHolder: 'Select a skill' - }); - await expect(dumpDom(card('agent'))).toMatchFileSnapshot('./__snapshots__/agent-skill-picked.dom.txt'); - - button(card('agent'), 'Save').click(); - await tick(); - expect(panel.last('dialogram.updateDefinitionAnnotation').argUpdates.skill).toBe('"turnus-reader"'); - }); - - it('says so when no skill was found', async () => { - const panel = new TestPanel(); - panel.show(taskNode('lonely', [ANALYST_AGENT])); - button(card('agent'), 'Pick Skill').click(); - await tick(); - expect(ui.calls.filter(c => c.method === 'infoMessage')).toEqual([ - { method: 'infoMessage', args: ['No discovered skills found in .wf/.claude roots.'] } - ]); - }); - - it('offers the agent presets the server found once the preset is enabled', async () => { - const node = taskNode('preset', [ANALYST_AGENT]); - const panel = new TestPanel(); - panel.show(node); - new WorkflowShowClaudeAgentsActionHandler().handle({ - kind: 'dialogram.showClaudeAgents', elementId: node.id, - agents: [{ name: 'code-reviewer', description: 'Reviews diffs', path: '/w/.claude/agents/code-reviewer.md', ignoredFields: ['tools'] }] - } as any); - const presetBlock = [...card('agent').querySelectorAll('.agent-skill-block')] - .find(b => b.querySelector('.agent-skill-label')?.textContent?.trim() === 'Agent Preset') as HTMLElement; - const toggle = presetBlock.querySelector('input.agent-enabled-input') as HTMLInputElement; - toggle.checked = true; - toggle.dispatchEvent(new Event('change', { bubbles: true })); - await tick(); - ui.picks.push('code-reviewer'); - button(presetBlock, 'Pick Preset').click(); - await tick(); - const pick = ui.calls.find(c => c.method === 'quickPick'); - await expect(JSON.stringify(pick?.args[0] ?? null, null, 2) + '\n').toMatchFileSnapshot('./__snapshots__/agent-preset.pick.json'); - await expect(dumpDom(presetBlock)).toMatchFileSnapshot('./__snapshots__/agent-preset-picked.dom.txt'); - button(card('agent'), 'Save').click(); - await tick(); - const op = panel.last('dialogram.updateDefinitionAnnotation'); - await expect(JSON.stringify(op.argUpdates, null, 2) + '\n').toMatchFileSnapshot('./__snapshots__/agent-preset-save.arg-updates.json'); - }); -}); - -describe('the annotation section’s buttons', () => { - it('adds an @agent and a @tool from their templates, and opens the wizard', async () => { - const panel = new TestPanel(); - panel.show(taskNode('plain', [])); - const header = section('Annotations')!.querySelector('.section-actions')!; - button(header, 'Add @agent').click(); - button(header, 'Add @tool').click(); - button(header, 'Wizard…').click(); - await tick(); - expect(panel.dispatched.slice(-3)).toEqual([ - { - kind: 'dialogram.updateDefinitionAnnotation', isOperation: true, elementId: 'node:scope:root:plain', - action: 'upsert', annotationName: 'agent', - annotationText: '@agent(\n prompt="Transform inputs to outputs",\n model="openai/gpt-5-mini"\n)' - }, - { - kind: 'dialogram.updateDefinitionAnnotation', isOperation: true, elementId: 'node:scope:root:plain', - action: 'upsert', annotationName: 'tool', - annotationText: '@tool(\n cmd="bash",\n args=["-lc", ""],\n inheritStdio=true\n)' - }, - { kind: 'dialogram.editAnnotations', elementId: 'node:scope:root:plain' } - ]); - }); -}); diff --git a/packages/diagram-client/test/agent-ui/agent-skill-status.test.ts b/packages/diagram-client/test/agent-ui/agent-skill-status.test.ts deleted file mode 100644 index d433fe0..0000000 --- a/packages/diagram-client/test/agent-ui/agent-skill-status.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -// @vitest-environment happy-dom -/** - * What the panel does for an agent node's configured skill, as it does it - * today: the Skill row of Basic Info, the skills and agent presets it takes - * from the root's snapshots before drawing, and the redraw when discovery - * answers for the node shown. Characterization before the move to wfpy-ide: - * see `harness.ts`. - */ -import { afterEach, beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'; -import { WorkflowDiagramMetadata } from '@dialogram/shared'; -import { WorkflowShowAgentSkillsActionHandler, WorkflowShowClaudeAgentsActionHandler } from '../../src/editing-action-handlers'; -import { installPage, taskNode, ANALYST_AGENT } from './harness'; -import { TestPanel, resetAgentUi, rootOf, section, tick } from './panel'; - -vi.mock('../../src/vscode-ui', async () => (await import('./harness')).vscodeUiModule()); - -const READER = { - name: 'turnus-reader', description: 'Reads Turnus analyses', root: '/w/.wf/skills', - skillDir: '/w/.wf/skills/turnus-reader', skillMdPath: '/w/.wf/skills/turnus-reader/SKILL.md', - hasSkillMd: true, hooks: [], declaredHooks: {} -}; -const PRESET = { name: 'reviewer', description: 'Reviews a change', path: '/w/.claude/agents/reviewer.md' }; - -/** An agent node whose spec names a skill, as the server sends it. */ -const skilled = (name: string) => taskNode(name, [ANALYST_AGENT], { 'wf:agentSpec': { skill: 'turnus-reader' } }); - -/** The Basic Info rows, label to value. */ -function infoRows(): Record { - const rows: Record = {}; - for (const row of section('Basic Info')!.querySelectorAll('.pp-arow')) { - rows[row.querySelector('.pp-akey')!.textContent!.trim()] = row.querySelector('.pp-aval')!.textContent!.replace(/\s+/g, ' ').trim(); - } - return rows; -} - -const kinds = (panel: TestPanel) => panel.dispatched.map(action => action.kind); - -beforeEach(() => { - resetAgentUi(); - installPage(); -}); - -afterEach(() => { - delete (globalThis as any).diagramIdentifier; -}); - -describe('the Skill row of an agent node', () => { - it('says the configured skill was not discovered, and writes that into the node', () => { - const node = skilled('lonely'); - new TestPanel().show(node); - expect(infoRows()).toMatchObject({ Name: 'lonely', Skill: 'Skill \'turnus-reader\' not discovered in configured roots.' }); - expect(node.args[WorkflowDiagramMetadata.AGENT_SKILL_STATUS]).toBe('Skill \'turnus-reader\' not discovered in configured roots.'); - }); - - it('describes the skill from the root’s snapshot, and keeps the snapshot’s presets for the node', () => { - const node = skilled('primed'); - const root = rootOf(node); - root.args = { - 'wf:agentSkillsSnapshot': JSON.stringify([READER]), - 'wf:claudeAgentsSnapshot': JSON.stringify([PRESET]) - }; - new TestPanel().show(node, root); - expect(infoRows().Skill).toBe('Reads Turnus analyses'); - expect(WorkflowShowAgentSkillsActionHandler.getSkillsForElement(node.id)).toEqual([READER]); - expect(WorkflowShowClaudeAgentsActionHandler.getAgentsForElement(node.id)).toEqual([PRESET]); - }); - - it('keeps what discovery found when the snapshot is empty', () => { - const node = skilled('kept'); - new WorkflowShowAgentSkillsActionHandler().handle({ kind: 'dialogram.showAgentSkills', elementId: node.id, skills: [READER] } as any); - const root = rootOf(node); - root.args = { 'wf:agentSkillsSnapshot': '[]' }; - new TestPanel().show(node, root); - expect(infoRows().Skill).toBe('Reads Turnus analyses'); - }); - - it('is absent when the spec names no skill', () => { - new TestPanel().show(taskNode('plain', [ANALYST_AGENT])); - expect(Object.keys(infoRows())).not.toContain('Skill'); - }); - - it('takes no snapshot for the node of a selected port', () => { - const node = skilled('ported'); - const port = { id: `${node.id}:in`, type: 'port', args: { [WorkflowDiagramMetadata.IS_INPUT_PORT]: true }, parent: node, children: [] }; - node.children = [port]; - const root = rootOf(node, port); - root.args = { 'wf:agentSkillsSnapshot': JSON.stringify([READER]) }; - const panel = new TestPanel(); - (panel as any).lastRoot = root; - (panel as any).updateContent(port); - expect(WorkflowShowAgentSkillsActionHandler.getSkillsForElement(node.id)).toEqual([]); - expect(infoRows().Skill).toBe('Skill \'turnus-reader\' not discovered in configured roots.'); - }); -}); - -describe('when discovery answers', () => { - function listening(): TestPanel { - const panel = new TestPanel(); - panel.listenForDiscovery(); - onTestFinished(() => panel.dispose()); - return panel; - } - - it('redraws the node shown with what it found, without asking for skills again', async () => { - const node = skilled('answered'); - const panel = listening(); - panel.show(node); - expect(kinds(panel)).toEqual(['dialogram.requestAgentSkills', 'dialogram.requestClaudeAgents']); - new WorkflowShowAgentSkillsActionHandler().handle({ kind: 'dialogram.showAgentSkills', elementId: node.id, skills: [READER] } as any); - await tick(); - expect(infoRows().Skill).toBe('Reads Turnus analyses'); - expect(kinds(panel)).toEqual(['dialogram.requestAgentSkills', 'dialogram.requestClaudeAgents', 'dialogram.requestClaudeAgents']); - }); - - it('keeps what it found when it redraws over the root’s older snapshot', async () => { - const node = skilled('fresh'); - const root = rootOf(node); - const stale = { ...READER, description: 'From the snapshot' }; - root.args = { 'wf:agentSkillsSnapshot': JSON.stringify([stale]), 'wf:claudeAgentsSnapshot': JSON.stringify([PRESET]) }; - const panel = listening(); - panel.show(node, root); - expect(infoRows().Skill).toBe('From the snapshot'); - - const newer = { ...PRESET, description: 'Reviews a change, newer' }; - new WorkflowShowClaudeAgentsActionHandler().handle({ kind: 'dialogram.showClaudeAgents', elementId: node.id, agents: [newer] } as any); - new WorkflowShowAgentSkillsActionHandler().handle({ kind: 'dialogram.showAgentSkills', elementId: node.id, skills: [READER] } as any); - await tick(); - expect(infoRows().Skill).toBe('Reads Turnus analyses'); - expect(WorkflowShowAgentSkillsActionHandler.getSkillsForElement(node.id)).toEqual([READER]); - expect(WorkflowShowClaudeAgentsActionHandler.getAgentsForElement(node.id)).toEqual([newer]); - }); - - it('does not redraw for another node', async () => { - const node = skilled('shown'); - const panel = listening(); - panel.show(node); - new WorkflowShowAgentSkillsActionHandler().handle({ kind: 'dialogram.showAgentSkills', elementId: 'node:scope:root:other', skills: [READER] } as any); - await tick(); - expect(kinds(panel)).toHaveLength(2); - expect(infoRows().Skill).toBe('Skill \'turnus-reader\' not discovered in configured roots.'); - }); - - it('does not redraw while the panel is collapsed', async () => { - const node = skilled('hidden'); - const panel = listening(); - panel.show(node); - document.getElementById('property-panel')!.classList.add('collapsed'); - new WorkflowShowAgentSkillsActionHandler().handle({ kind: 'dialogram.showAgentSkills', elementId: node.id, skills: [READER] } as any); - await tick(); - expect(kinds(panel)).toHaveLength(2); - }); -}); diff --git a/packages/diagram-client/test/agent-ui/agent-tools-and-context.test.ts b/packages/diagram-client/test/agent-ui/agent-tools-and-context.test.ts deleted file mode 100644 index 6e5d46d..0000000 --- a/packages/diagram-client/test/agent-ui/agent-tools-and-context.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -// @vitest-environment happy-dom -/** - * The Agent Tools section, the agent-context section and the `@tool` card, as - * the property panel draws and sends them today. Characterization before the - * move to wfpy-ide: see `harness.ts`. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { WorkflowDiagramMetadata } from '@dialogram/shared'; -import { ui, installPage, taskNode, dumpDom, ANALYST_AGENT, BASH_TOOL } from './harness'; -import { TestPanel, button, card, resetAgentUi, rootOf, section, tick } from './panel'; - -vi.mock('../../src/vscode-ui', async () => (await import('./harness')).vscodeUiModule()); - -beforeEach(() => { - resetAgentUi(); - installPage(); -}); - -afterEach(() => { - delete (globalThis as any).diagramIdentifier; -}); - -describe('Agent Tools', () => { - it('shows the host’s settings for the agent, and sends each change', async () => { - ui.answers.set('wfpy.glsp.getAgentToolConfig', { enabled: true, auth: 'policy' }); - const panel = new TestPanel(); - panel.show(taskNode('tooled', [ANALYST_AGENT])); - await tick(); - const tools = section('Agent Tools')!; - await expect(dumpDom(tools)).toMatchFileSnapshot('./__snapshots__/agent-tools.dom.txt'); - - // As a person can: pick another auth mode while the tools are on, then turn them off. - const auth = tools.querySelector('select') as HTMLSelectElement; - expect(auth.disabled).toBe(false); - auth.value = 'deny-all'; - auth.dispatchEvent(new Event('change', { bubbles: true })); - const enabled = tools.querySelector('input[type="checkbox"]') as HTMLInputElement; - enabled.checked = false; - enabled.dispatchEvent(new Event('change', { bubbles: true })); - await tick(); - expect(auth.disabled).toBe(true); - await expect(JSON.stringify(ui.calls, null, 2) + '\n').toMatchFileSnapshot('./__snapshots__/agent-tools.calls.json'); - }); - - it('is not offered where no run driver is behind the diagram', async () => { - installPage({ runDriver: false }); - const panel = new TestPanel(); - panel.show(taskNode('unrun', [ANALYST_AGENT])); - await tick(); - expect(section('Agent Tools')).toBeUndefined(); - expect(ui.calls).toEqual([]); - }); - - it('is not offered for a node without @agent', async () => { - const panel = new TestPanel(); - panel.show(taskNode('toolonly', [BASH_TOOL])); - await tick(); - expect(section('Agent Tools')).toBeUndefined(); - }); -}); - -/** A stateful agent's history as the run overlay stamps it on the node. */ -const HISTORY = [ - { role: 'system', content: 'You are the analyst.' }, - // The shape wfpy's runtime writes for an agent's input: its task, its instance, the inputs by port. - { - role: 'user', - content: JSON.stringify({ - task: 'Analyst', - instance: 'analyst', - inputs: { round: '/w/out/r3/round.json' }, - fileInputs: { round: { path: '/w/out/r3/round.json', content: '{"round": 3, "cycles": {"dhrystone": 41210}}' } } - }) - }, - { role: 'assistant', content: '## Budget\n\n- dhrystone: **12%** of the path\n- fetch still on the path', thinking: 'Compare with round 2.' }, - { role: 'assistant', content: 'x'.repeat(450) } -]; - -function withHistory(name: string, history: unknown[]): any { - return taskNode(name, [ANALYST_AGENT], { - [WorkflowDiagramMetadata.AGENT_CHAT_HISTORY]: JSON.stringify(history), - [WorkflowDiagramMetadata.AGENT_FIRE_COUNT]: 3, - [WorkflowDiagramMetadata.AGENT_CONTEXT_BUDGET]: 50, - [WorkflowDiagramMetadata.AGENT_TRUNCATION_STRATEGY]: 'sliding', - [WorkflowDiagramMetadata.AGENT_MODEL]: 'opus' - }); -} - -describe('the agent-context section', () => { - it('shows a stateful agent’s conversation as today', async () => { - const panel = new TestPanel(); - panel.show(withHistory('talker', HISTORY)); - await tick(); - const context = section('Agent Context')!; - expect(context).toBeDefined(); - await expect(dumpDom(context)).toMatchFileSnapshot('./__snapshots__/agent-context.dom.txt'); - }); - - it('is absent without a history, and when the history is not JSON', async () => { - const panel = new TestPanel(); - panel.show(taskNode('silent', [ANALYST_AGENT])); - expect(section('Agent Context')).toBeUndefined(); - panel.show(taskNode('garbled', [ANALYST_AGENT], { [WorkflowDiagramMetadata.AGENT_CHAT_HISTORY]: '{not json' })); - expect(section('Agent Context')).toBeUndefined(); - }); - - it('follows the run: a new history on the shown node replaces the section in place', async () => { - const first = withHistory('streamer', HISTORY.slice(0, 2)); - const panel = new TestPanel(); - panel.show(first); - const before = section('Agent Context')!; - const annotations = section('Annotations')!; - expect(before.querySelector('.section-title')?.textContent).toBe('Agent Context (2/50)'); - - const next = withHistory('streamer', HISTORY); - panel.modelRootChanged(rootOf(next)); - await tick(); - const after = section('Agent Context')!; - expect(after.querySelector('.section-title')?.textContent).toBe('Agent Context (4/50)'); - // In place: the rest of the panel is the same DOM, not a re-render. - expect(section('Annotations')).toBe(annotations); - expect([...document.querySelectorAll('.property-section')] - .filter(s => s.querySelector('.section-title')?.textContent?.startsWith('Agent Context'))).toHaveLength(1); - }); -}); - -describe('a @tool node', () => { - it('draws its editor as today and saves the whole annotation', async () => { - const panel = new TestPanel(); - panel.show(taskNode('builder', [BASH_TOOL])); - await tick(); - await expect(dumpDom(card('tool'))).toMatchFileSnapshot('./__snapshots__/tool-card.dom.txt'); - button(card('tool'), 'Save').click(); - await tick(); - const op = panel.last('dialogram.updateDefinitionAnnotation'); - expect(op).toMatchObject({ elementId: 'node:scope:root:builder', action: 'upsert', annotationName: 'tool' }); - // KNOWN DEFECT, pinned as it ships: the text says `inherit_stdio=True` while - // the argument updates say `inheritStdio: "true"`, so the operation names one - // argument two ways. Fixing it changes these two snapshots on purpose. - expect(op.annotationText).toContain('inherit_stdio=True'); - expect(op.argUpdates.inheritStdio).toBe('true'); - await expect(op.annotationText + '\n').toMatchFileSnapshot('./__snapshots__/tool-save.annotation.txt'); - await expect(JSON.stringify(op.argUpdates, null, 2) + '\n').toMatchFileSnapshot('./__snapshots__/tool-save.arg-updates.json'); - }); -}); diff --git a/packages/diagram-client/test/agent-ui/annotation-wizard.test.ts b/packages/diagram-client/test/agent-ui/annotation-wizard.test.ts deleted file mode 100644 index 17bcea7..0000000 --- a/packages/diagram-client/test/agent-ui/annotation-wizard.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -// @vitest-environment happy-dom -/** - * The annotation wizard's `@tool` and `@viewer` templates, as they write the - * annotation today. Characterization before the move to wfpy-ide: see - * `harness.ts`. The wizard is the Annotations section's "Wizard…" button. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { WorkflowEditAnnotationsActionHandler } from '../../src/editing-action-handlers'; -import { agentUiTemplates } from '../../src/agent-ui'; -import { ui, installPage, taskNode, BASH_TOOL } from './harness'; -import { resetAgentUi, rootOf } from './panel'; - -vi.mock('../../src/vscode-ui', async () => (await import('./harness')).vscodeUiModule()); - -class TestWizard extends WorkflowEditAnnotationsActionHandler { - readonly dispatched: any[] = []; - constructor(node: any) { - super(); - // What `agentUiModule` binds. - (this as any).contributedTemplates = agentUiTemplates(); - (this as any).editorContext = { modelRoot: rootOf(node) }; - (this as any).actionDispatcher = { dispatch: async (a: unknown) => { this.dispatched.push(a); } }; - } - run(elementId: string): Promise { - return this.promptAndUpdate({ kind: 'dialogram.editAnnotations', elementId } as any); - } -} - -async function wizard(answers: unknown[], annotations: any[] = []): Promise<{ sent: any[]; asked: unknown[] }> { - const node = taskNode('wizarded', annotations); - const handler = new TestWizard(node); - ui.picks.push(...answers); - await handler.run(node.id); - return { - sent: handler.dispatched, - asked: ui.calls.filter(c => c.method === 'quickPick' || c.method === 'inputBox').map(c => c.args[0]) - }; -} - -beforeEach(() => { - resetAgentUi(); - installPage(); -}); - -afterEach(() => { - delete (globalThis as any).diagramIdentifier; -}); - -describe('the annotation wizard', () => { - it('offers the operations, then the templates', async () => { - const { asked } = await wizard(['upsert']); - await expect(JSON.stringify(asked, null, 2) + '\n').toMatchFileSnapshot('./__snapshots__/wizard-menus.json'); - }); - - it('writes a @tool', async () => { - const { sent, asked } = await wizard(['upsert', 'tool', 'make', '["-C", "build", "all"]', 'false']); - expect(asked.length).toBe(5); - expect(sent).toEqual([{ - kind: 'dialogram.updateDefinitionAnnotation', isOperation: true, elementId: 'node:scope:root:wizarded', - action: 'upsert', annotationName: 'tool', - annotationText: '@tool(\n cmd="make",\n args=["-C", "build", "all"],\n inheritStdio=false\n)' - }]); - }); - - it('writes a @viewer that opens the last token', async () => { - const { sent } = await wizard(['upsert', 'viewer', 'open', '["report"]']); - expect(sent[0].annotationName).toBe('viewer'); - expect(sent[0].annotationText).toBe('@viewer(\n action="open",\n inputs=["report"],\n token="last"\n)'); - }); - - it('writes a @viewer that diffs two last tokens, titled', async () => { - const { sent } = await wizard(['upsert', 'viewer', 'diff', '["left", "right"]', 'Round diff']); - expect(sent[0].annotationText).toBe( - '@viewer(\n action="diff",\n inputs=["left", "right"],\n token="last",\n title="Round diff"\n)' - ); - }); - - it('writes a @viewer that opens with an editor from the settings', async () => { - ui.answers.set('getViewerEditors', [{ viewType: 'hexEditor.hexedit', label: 'Hex Editor' }]); - const { sent, asked } = await wizard(['upsert', 'viewer', 'openWith', '["dump"]', 'hexEditor.hexedit']); - await expect(JSON.stringify(asked[4], null, 2) + '\n').toMatchFileSnapshot('./__snapshots__/wizard-viewer-editors.json'); - expect(sent[0].annotationText).toBe( - '@viewer(\n action="openWith",\n inputs=["dump"],\n token="last",\n viewType="hexEditor.hexedit"\n)' - ); - }); - - it('remembers a custom editor in the settings when asked', async () => { - ui.answers.set('getViewerEditors', []); - const { sent } = await wizard(['upsert', 'viewer', 'openWith', '["dump"]', '__custom__', 'my.viewer', 'yes', 'My viewer']); - expect(ui.calls.filter(c => c.method === 'appendViewerEditor')).toEqual([ - { method: 'appendViewerEditor', args: [{ viewType: 'my.viewer', label: 'My viewer' }] } - ]); - expect(sent[0].annotationText).toContain('viewType="my.viewer"'); - }); - - it('removes an annotation the definition has', async () => { - const { sent } = await wizard(['remove', 'tool'], [BASH_TOOL]); - expect(sent).toEqual([{ - kind: 'dialogram.updateDefinitionAnnotation', isOperation: true, elementId: 'node:scope:root:wizarded', - action: 'remove', annotationName: 'tool' - }]); - }); -}); diff --git a/packages/diagram-client/test/agent-ui/harness.ts b/packages/diagram-client/test/agent-ui/harness.ts deleted file mode 100644 index 9bec03a..0000000 --- a/packages/diagram-client/test/agent-ui/harness.ts +++ /dev/null @@ -1,202 +0,0 @@ -/** - * A harness for the property panel's wfpy agent UI, in a real DOM (happy-dom). - * - * The panel is the shipped class with its window wiring skipped (see - * `panel.ts`). Its - * dispatcher and the VS Code bridge are recorders, so a test reads back what - * the panel drew and what it sent. Everything else is the real code: lit, the - * Markdown renderer and DOMPurify run against happy-dom's window. - * - * These tests characterize what the agent UI does today, before it moves to - * wfpy-ide: they pin its output, they do not argue it is right. - */ -import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; - -export interface UiCall { method: string; args: unknown[] } - -// This module must not import panel code: the VS Code bridge mock loads it, -// and panel code imports the bridge (a cycle the module loader waits on forever). -export const ui = { - calls: [] as UiCall[], - /** What `executeCommand(id)` answers, by command id; any other method, by its name. */ - answers: new Map(), - /** What `quickPick` / `inputBox` answer, in order. */ - picks: [] as unknown[], - reset(): void { - this.calls.length = 0; - this.answers.clear(); - this.picks.length = 0; - } -}; - -/** What the real bridge answers when a test does not say: its declared types' empty values. */ -const DEFAULT_ANSWERS: Record = { getViewerEditors: [], appendViewerEditor: true }; - -/** - * The VS Code bridge as a recorder. A test file mocks `../../src/vscode-ui` with - * `vi.mock('../../src/vscode-ui', async () => (await import('./harness')).vscodeUiModule())`. - */ -export function vscodeUiModule(): unknown { - const record = (method: string) => async (...args: unknown[]) => { - ui.calls.push({ method, args }); - if (method === 'executeCommand') { - return ui.answers.get(String(args[0])); - } - if (method === 'quickPick' || method === 'inputBox') { - return ui.picks.shift(); - } - return ui.answers.has(method) ? ui.answers.get(method) : DEFAULT_ANSWERS[method]; - }; - const instance = { - executeCommand: record('executeCommand'), - infoMessage: record('infoMessage'), - errorMessage: record('errorMessage'), - inputBox: record('inputBox'), - quickPick: record('quickPick'), - getViewerEditors: record('getViewerEditors'), - appendViewerEditor: record('appendViewerEditor') - }; - return { VscodeUi: { get instance() { return instance; } } }; -} - -/** The commands the host registers for an agent's tool settings (the run driver's). */ -export const COMMAND_IDS = { - setAgentToolConfig: 'wfpy.glsp.setAgentToolConfig', - getAgentToolConfig: 'wfpy.glsp.getAgentToolConfig', - runWorkflow: 'wfpy.glsp.runWorkflow', - stopWorkflow: 'wfpy.glsp.stopWorkflow' -}; - -/** The webview page the platform writes, and the behaviour wfpy-ide injects. */ -export function installPage(behavior: Record = {}): void { - document.body.innerHTML = '
'; - (globalThis as any).diagramIdentifier = { - clientId: 'test', - settingsNamespace: 'wfLang', - commandIds: COMMAND_IDS, - clientBehavior: { - runDriver: true, - noneSentinel: 'wfpy-none', - acpConnectors: [ - { name: 'claude-code', available: true, source: 'discovered', command: 'claude-code-acp' }, - { name: 'opencode', available: false, source: 'discovered', command: 'opencode', httpApi: true } - ], - ...behavior - } - }; -} - -/** An argument as the producer writes it: a Python expression. */ -export type AnnotationArg = { name: string; value: string }; - -/** A task node with the given definition annotations, as the server sends it. */ -export function taskNode( - name: string, - annotations: Array<{ name: string; arguments: AnnotationArg[] }>, - extra: Record = {} -): any { - return { - id: `node:scope:root:${name}`, - type: WorkflowDiagramTypes.NODE_EXTERNAL_ACTOR, - args: { - [WorkflowDiagramMetadata.ENTITY_NAME]: name, - [WorkflowDiagramMetadata.ENTITY_TYPE]: name[0].toUpperCase() + name.slice(1), - [WorkflowDiagramMetadata.IS_EXTERNAL_ACTOR]: true, - [WorkflowDiagramMetadata.ENTITY_DEFINITION_ANNOTATIONS]: annotations, - ...extra - }, - children: [] - }; -} - -/** The analyst agent of `diagram-server/test/fixtures-speculation-flow.json`, prompt shortened. */ -export const ANALYST_AGENT = { - name: 'agent', - arguments: [ - { name: 'prompt', value: '"You are the analyst of a hardware design loop.\\nSay, per program, the budget in cycles."' }, - { name: 'model', value: '"opus"' }, - { name: 'timeoutMs', value: '1800000' }, - { name: 'contextBudget', value: '50' }, - { name: 'useClaudeAgent', value: 'false' }, - { name: 'useSkill', value: 'true' }, - { name: 'usePrompt', value: 'true' }, - { name: 'stateful', value: 'false' }, - { name: 'truncationStrategy', value: '"sliding"' }, - { name: 'transport', value: '"claude-cli"' }, - { name: 'reasoningEffort', value: '"None"' }, - { name: 'askUser', value: 'true' }, - { name: 'cliToolsMode', value: '"native"' } - ] -}; - -/** A `@tool` as the annotation wizard's template writes it. */ -export const BASH_TOOL = { - name: 'tool', - arguments: [ - { name: 'cmd', value: '"bash"' }, - { name: 'args', value: '["-lc", "make all"]' }, - { name: 'inheritStdio', value: 'true' } - ] -}; - -/** - * The panel's DOM as indented text: tag, class, the attributes a person sees - * or a control carries (inline styles too: the panel draws switches, dimming - * and hiding with them), and trimmed text. Lit's comment markers are dropped. - */ -export function dumpDom(root: Element): string { - const lines: string[] = []; - const ATTRS = ['type', 'title', 'placeholder', 'value', 'checked', 'selected', 'disabled', 'href', 'data-annotation', 'data-arg', 'aria-label', 'role', 'style']; - const walk = (node: Node, depth: number): void => { - if (node.nodeType === 3) { - const text = (node.textContent ?? '').replace(/\s+/g, ' ').trim(); - if (text) { - lines.push(`${' '.repeat(depth)}"${text}"`); - } - return; - } - if (node.nodeType !== 1) { - return; - } - const el = node as HTMLElement; - const tag = el.tagName.toLowerCase(); - const parts = [tag]; - if (el.className && typeof el.className === 'string') { - parts.push(`.${el.className.trim().split(/\s+/).join('.')}`); - } - for (const attr of ATTRS) { - if (attr === 'value' && (tag === 'input' || tag === 'textarea' || tag === 'select')) { - const value = (el as HTMLInputElement).value; - if (value !== '') { - parts.push(`value=${JSON.stringify(value)}`); - } - continue; - } - if (attr === 'checked' && tag === 'input') { - if ((el as HTMLInputElement).checked) { - parts.push('checked'); - } - continue; - } - if (attr === 'selected' && tag === 'option') { - if ((el as HTMLOptionElement).selected) { - parts.push('selected'); - } - continue; - } - const value = el.getAttribute(attr); - if (value !== null) { - parts.push(value === '' ? attr : `${attr}=${JSON.stringify(value)}`); - } - } - if (el.hidden) { - parts.push('[hidden]'); - } - lines.push(`${' '.repeat(depth)}${parts.join(' ')}`); - for (const child of Array.from(el.childNodes)) { - walk(child, depth + 1); - } - }; - walk(root, 0); - return lines.join('\n') + '\n'; -} diff --git a/packages/diagram-client/test/annotation-editor-seam.test.ts b/packages/diagram-client/test/annotation-editor-seam.test.ts index 9ddeab1..e6e8306 100644 --- a/packages/diagram-client/test/annotation-editor-seam.test.ts +++ b/packages/diagram-client/test/annotation-editor-seam.test.ts @@ -12,11 +12,10 @@ import { ChangeBoundsTool, TYPES } from '@eclipse-glsp/client'; import { AnnotationEditor, type AnnotationEditorContext } from '../src/annotation-editor'; import { PropertyPanel } from '../src/property-panel'; import { workflowFeaturesModule } from '../src/stock-features.module'; -import { agentUiModule } from '../src/agent-ui'; -import { installPage, taskNode } from './agent-ui/harness'; -import { TestPanel, button, card, resetAgentUi, rootOf, section, tick } from './agent-ui/panel'; +import { installPage, taskNode } from './panel-harness/harness'; +import { TestPanel, button, card, resetPanel, rootOf, section, tick } from './panel-harness/panel'; -vi.mock('../src/vscode-ui', async () => (await import('./agent-ui/harness')).vscodeUiModule()); +vi.mock('../src/vscode-ui', async () => (await import('./panel-harness/harness')).vscodeUiModule()); // The stock module starts loading the edge router's WASM; nothing here routes. vi.mock('../src/libavoid-loader', async (original) => ({ ...(await original()), @@ -62,7 +61,7 @@ const headerButtons = () => [...section('Annotations')!.querySelectorAll('.section-actions button')].map(b => b.textContent); beforeEach(() => { - resetAgentUi(); + resetPanel(); installPage(); }); @@ -188,20 +187,27 @@ describe('the editors the panel is given', () => { expect(names(stockContainer(product).get(PropertyPanel))).toEqual(['probe']); }); - it('are the agent UI’s @tool and @agent when a bundle loads it, then another module’s', () => { - const product = new ContainerModule(bind => { + it('are those every module binds, in the order they were bound', () => { + const first = new ContainerModule(bind => { + bind(AnnotationEditor).toConstantValue(probeEditor([], 'tool')); + bind(AnnotationEditor).toConstantValue(probeEditor([], 'agent')); + }); + const second = new ContainerModule(bind => { bind(AnnotationEditor).toConstantValue(probeEditor()); }); - expect(names(stockContainer(agentUiModule).get(PropertyPanel))).toEqual(['tool', 'agent']); - expect(names(stockContainer(agentUiModule, product).get(PropertyPanel))).toEqual(['tool', 'agent', 'probe']); + expect(names(stockContainer(first, second).get(PropertyPanel))).toEqual(['tool', 'agent', 'probe']); }); it('take a later module’s editor in place of an earlier one’s for its annotation', () => { const mine = probeEditor([], 'agent'); - const product = new ContainerModule(bind => { + const first = new ContainerModule(bind => { + bind(AnnotationEditor).toConstantValue(probeEditor([], 'tool')); + bind(AnnotationEditor).toConstantValue(probeEditor([], 'agent')); + }); + const second = new ContainerModule(bind => { bind(AnnotationEditor).toConstantValue(mine); }); - const editors = (stockContainer(agentUiModule, product).get(PropertyPanel) as any).annotationEditors(); + const editors = (stockContainer(first, second).get(PropertyPanel) as any).annotationEditors(); expect(editors.map((e: AnnotationEditor) => e.annotationName)).toEqual(['tool', 'agent']); expect(editors[1]).toBe(mine); }); diff --git a/packages/diagram-client/test/annotation-template-seam.test.ts b/packages/diagram-client/test/annotation-template-seam.test.ts index 10de61b..c40b987 100644 --- a/packages/diagram-client/test/annotation-template-seam.test.ts +++ b/packages/diagram-client/test/annotation-template-seam.test.ts @@ -9,12 +9,11 @@ import { Container, ContainerModule } from 'inversify'; import { ChangeBoundsTool, EditorContextService, TYPES } from '@eclipse-glsp/client'; import { AnnotationTemplate, type AnnotationTemplateContext } from '../src/annotation-template'; import { WorkflowEditAnnotationsActionHandler } from '../src/editing-action-handlers'; -import { agentUiModule, agentUiTemplates } from '../src/agent-ui'; import { workflowFeaturesModule } from '../src/stock-features.module'; -import { ui, installPage, taskNode } from './agent-ui/harness'; -import { resetAgentUi, rootOf } from './agent-ui/panel'; +import { ui, installPage, taskNode } from './panel-harness/harness'; +import { resetPanel, rootOf } from './panel-harness/panel'; -vi.mock('../src/vscode-ui', async () => (await import('./agent-ui/harness')).vscodeUiModule()); +vi.mock('../src/vscode-ui', async () => (await import('./panel-harness/harness')).vscodeUiModule()); // The stock module starts loading the edge router's WASM; nothing here routes. vi.mock('../src/libavoid-loader', async (original) => ({ ...(await original()), @@ -57,7 +56,7 @@ async function wizard(templates: AnnotationTemplate[] | undefined, answers: unkn } beforeEach(() => { - resetAgentUi(); + resetPanel(); installPage(); }); @@ -80,7 +79,7 @@ describe('an annotation template', () => { it('sends nothing when the person cancels it, or it composes nothing', async () => { expect((await wizard([probe('retry', undefined)], ['upsert', 'retry'])).sent).toEqual([]); - resetAgentUi(); + resetPanel(); expect((await wizard([probe('retry', ' ')], ['upsert', 'retry'])).sent).toEqual([]); }); @@ -114,26 +113,6 @@ describe('the wizard without templates', () => { }); }); -describe('the stock templates', () => { - it('escape a newline, a tab and a quote in a string they write', async () => { - const viewer = await wizard(agentUiTemplates(), ['upsert', 'viewer', 'diff', '["a", "b"]', 'Before\nafter\t"x"']); - expect(viewer.sent[0].annotationText).toBe('@viewer(\n action="diff",\n inputs=["a", "b"],\n token="last",\n title="Before\\nafter\\t\\"x\\""\n)'); - resetAgentUi(); - const tool = await wizard(agentUiTemplates(), ['upsert', 'tool', 'my\ttool', '[]', 'true']); - expect(tool.sent[0].annotationText).toBe('@tool(\n cmd="my\\ttool",\n args=[],\n inheritStdio=true\n)'); - }); - - it('offer a @tool’s command as it reads, so writing it back changes nothing', async () => { - const literal = '"my\\ttool \\"x\\""'; // the source text: "my\ttool \"x\"" - const { sent } = await wizard(agentUiTemplates(), - ['upsert', 'tool', 'my\ttool "x"', '[]', 'true'], - [{ name: 'tool', arguments: [{ name: 'cmd', value: literal }, { name: 'args', value: '[]' }] }]); - const cmdAsk = ui.calls.find(c => c.method === 'inputBox' && String((c.args[0] as any).prompt).startsWith('tool.cmd')); - expect((cmdAsk!.args[0] as any).value).toBe('my\ttool "x"'); - expect(sent[0].annotationText).toBe(`@tool(\n cmd=${literal},\n args=[],\n inheritStdio=true\n)`); - }); -}); - describe('the templates the wizard is given', () => { const dispatcher = { dispatch: async () => undefined, dispatchAll: async () => undefined }; @@ -157,12 +136,15 @@ describe('the templates the wizard is given', () => { expect(names(container(product).resolve(WorkflowEditAnnotationsActionHandler))).toEqual(['retry']); }); - it('are the agent UI’s @tool and @viewer when a bundle loads it, then another module’s', () => { - const product = new ContainerModule(bind => { + it('are those every module binds, in the order they were bound', () => { + const first = new ContainerModule(bind => { + bind(AnnotationTemplate).toConstantValue(probe('tool', '@tool')); + bind(AnnotationTemplate).toConstantValue(probe('viewer', '@viewer')); + }); + const second = new ContainerModule(bind => { bind(AnnotationTemplate).toConstantValue(probe('retry', '@retry')); }); - expect(names(container(agentUiModule).resolve(WorkflowEditAnnotationsActionHandler))).toEqual(['tool', 'viewer']); - expect(names(container(agentUiModule, product).resolve(WorkflowEditAnnotationsActionHandler))).toEqual(['tool', 'viewer', 'retry']); + expect(names(container(first, second).resolve(WorkflowEditAnnotationsActionHandler))).toEqual(['tool', 'viewer', 'retry']); }); it('are none when nothing is bound', () => { diff --git a/packages/diagram-client/test/fixtures/agent-ui-consumer/entry.ts b/packages/diagram-client/test/fixtures/agent-ui-consumer/entry.ts deleted file mode 100644 index cccbf34..0000000 --- a/packages/diagram-client/test/fixtures/agent-ui-consumer/entry.ts +++ /dev/null @@ -1,7 +0,0 @@ -// A product's own webview bundle that loads the agent workflow UI itself, -// through the path import `src/agent-ui/index.ts` documents. -import { startStockDiagram } from '@dialogram/diagram-client/stock-starter'; -import '@dialogram/diagram-client/diagram-client.css'; -import { agentUiModule } from '@dialogram/diagram-client/agent-ui/index'; - -startStockDiagram({ features: [agentUiModule] }); diff --git a/packages/diagram-client/test/node-section-seam.test.ts b/packages/diagram-client/test/node-section-seam.test.ts index 4212979..2de7ae8 100644 --- a/packages/diagram-client/test/node-section-seam.test.ts +++ b/packages/diagram-client/test/node-section-seam.test.ts @@ -14,11 +14,10 @@ import { NodeSectionContribution, type NodeSectionHost } from '../src/node-secti import { createPropertySection } from '../src/property-section'; import { PropertyPanel } from '../src/property-panel'; import { workflowFeaturesModule } from '../src/stock-features.module'; -import { agentUiModule } from '../src/agent-ui'; -import { installPage, taskNode, ANALYST_AGENT } from './agent-ui/harness'; -import { TestPanel, resetAgentUi, rootOf, section } from './agent-ui/panel'; +import { installPage, taskNode } from './panel-harness/harness'; +import { TestPanel, resetPanel, rootOf, section } from './panel-harness/panel'; -vi.mock('../src/vscode-ui', async () => (await import('./agent-ui/harness')).vscodeUiModule()); +vi.mock('../src/vscode-ui', async () => (await import('./panel-harness/harness')).vscodeUiModule()); // The stock module starts loading the edge router's WASM; nothing here routes. vi.mock('../src/libavoid-loader', async (original) => ({ ...(await original()), @@ -69,7 +68,7 @@ const sectionTitles = () => const infoKeys = () => [...section('Basic Info')!.querySelectorAll('.pp-akey')].map(k => k.textContent!.trim()); beforeEach(() => { - resetAgentUi(); + resetPanel(); installPage(); }); @@ -169,16 +168,22 @@ describe('a node section contribution', () => { }); }); -describe('an agent node without the agent sections', () => { - it('has no Skill row, no Agent Tools and no Agent Context', () => { - const node = taskNode('bare', [ANALYST_AGENT], { - 'wf:agentSpec': { skill: 'turnus-reader' }, - [WorkflowDiagramMetadata.AGENT_CHAT_HISTORY]: JSON.stringify([{ role: 'user', content: 'hi' }]) - }); - panelWith(undefined).show(node); - expect(infoKeys()).toEqual(['Name', 'Type']); - expect(sectionTitles()).not.toContain('Agent Tools'); - expect(sectionTitles().some(t => t.startsWith('Agent Context'))).toBe(false); +describe('a redraw a contribution asks for', () => { + it('tells the annotation editors not to ask for discovery again', () => { + const asked: boolean[] = []; + const editor = { + annotationName: 'probe', + reservedArgs: [], + render: (context: { requestDiscovery: boolean }) => { asked.push(context.requestDiscovery); } + }; + const contribution = probe(); + const panel = panelWith([contribution]); + (panel as any).contributedAnnotationEditors = [editor]; + (panel as any).installNodeSections(); + const node = taskNode('asking', [{ name: 'probe', arguments: [] }]); + panel.show(node); + contribution.host!.redrawIfShowing(node.id); + expect(asked).toEqual([true, false]); }); }); @@ -207,25 +212,29 @@ describe('the node sections the panel is given', () => { expect(calls).toEqual([['install']]); }); - it('are the agent UI’s sections when a bundle loads it, then another module’s, each installed once after injection', () => { - const calls: Call[] = []; - const product = new ContainerModule(bind => { - bind(NodeSectionContribution).toConstantValue(probe(calls)); - }); - const panel = stockContainer(agentUiModule, product).get(PropertyPanel); - expect(kinds(panel)).toEqual(['AgentNodeSections', 'product']); - expect(calls).toEqual([['install']]); + it('are those every module binds, in the order they were bound, each installed once after injection', () => { + const first: Call[] = []; + const second: Call[] = []; + const one = probe(first, 'one'); + const two = probe(second, 'two'); + const panel = stockContainer( + new ContainerModule(bind => { bind(NodeSectionContribution).toConstantValue(one); }), + new ContainerModule(bind => { bind(NodeSectionContribution).toConstantValue(two); }) + ).get(PropertyPanel); + expect((panel as any).nodeSections()).toEqual([one, two]); + expect([first, second]).toEqual([[['install']], [['install']]]); }); - it('take a later module’s agent sections in place of the agent UI’s', () => { + it('take a later module’s contribution in place of an earlier one’s of the same id', () => { + const earlier: Call[] = []; const calls: Call[] = []; const mine = probe(calls, 'mine', 'agent'); - const product = new ContainerModule(bind => { - bind(NodeSectionContribution).toConstantValue(mine); - }); - const panel = stockContainer(agentUiModule, product).get(PropertyPanel); + const panel = stockContainer( + new ContainerModule(bind => { bind(NodeSectionContribution).toConstantValue(probe(earlier, 'theirs', 'agent')); }), + new ContainerModule(bind => { bind(NodeSectionContribution).toConstantValue(mine); }) + ).get(PropertyPanel); expect((panel as any).nodeSections()).toEqual([mine]); - expect(calls).toEqual([['install']]); + expect([earlier, calls]).toEqual([[], [['install']]]); }); it('are none when nothing is bound', () => { diff --git a/packages/diagram-client/test/panel-harness/harness.ts b/packages/diagram-client/test/panel-harness/harness.ts new file mode 100644 index 0000000..08a6af6 --- /dev/null +++ b/packages/diagram-client/test/panel-harness/harness.ts @@ -0,0 +1,86 @@ +/** + * The property panel in a real DOM (happy-dom), for the contribution-point + * tests: the webview page the platform writes, a node as the server sends + * it, and the VS Code bridge as a recorder, so a test reads back what the + * panel drew and what it asked the host. + * + * Grew out of the agent-UI characterization (#132), which moved to wfpy-ide + * with the code it pinned. + */ +import { WorkflowDiagramMetadata, WorkflowDiagramTypes } from '@dialogram/shared'; + +export interface UiCall { method: string; args: unknown[] } + +// This module must not import panel code: the VS Code bridge mock loads it, +// and panel code imports the bridge (a cycle the module loader waits on forever). +export const ui = { + calls: [] as UiCall[], + /** What `executeCommand(id)` answers, by command id; any other method, by its name. */ + answers: new Map(), + /** What `quickPick` / `inputBox` answer, in order. */ + picks: [] as unknown[], + reset(): void { + this.calls.length = 0; + this.answers.clear(); + this.picks.length = 0; + } +}; + + +/** + * The VS Code bridge as a recorder. A test file mocks `../src/vscode-ui` with + * `vi.mock('../src/vscode-ui', async () => (await import('./panel-harness/harness')).vscodeUiModule())`. + */ +export function vscodeUiModule(): unknown { + const record = (method: string) => async (...args: unknown[]) => { + ui.calls.push({ method, args }); + if (method === 'executeCommand') { + return ui.answers.get(String(args[0])); + } + if (method === 'quickPick' || method === 'inputBox') { + return ui.picks.shift(); + } + return ui.answers.get(method); + }; + const instance = { + executeCommand: record('executeCommand'), + infoMessage: record('infoMessage'), + errorMessage: record('errorMessage'), + inputBox: record('inputBox'), + quickPick: record('quickPick') + }; + return { VscodeUi: { get instance() { return instance; } } }; +} + +/** The webview page the platform writes, with the client behaviour given. */ +export function installPage(behavior: Record = {}): void { + document.body.innerHTML = '
'; + (globalThis as any).diagramIdentifier = { + clientId: 'test', + settingsNamespace: 'test', + commandIds: {}, + clientBehavior: { runDriver: true, ...behavior } + }; +} + +export type AnnotationArg = { name: string; value: string }; + +/** A task node whose definition carries `annotations`, as the server sends it. */ +export function taskNode( + name: string, + annotations: Array<{ name: string; arguments: AnnotationArg[] }>, + extra: Record = {} +): any { + return { + id: `node:scope:root:${name}`, + type: WorkflowDiagramTypes.NODE_EXTERNAL_ACTOR, + args: { + [WorkflowDiagramMetadata.ENTITY_NAME]: name, + [WorkflowDiagramMetadata.ENTITY_TYPE]: name[0].toUpperCase() + name.slice(1), + [WorkflowDiagramMetadata.IS_EXTERNAL_ACTOR]: true, + [WorkflowDiagramMetadata.ENTITY_DEFINITION_ANNOTATIONS]: annotations, + ...extra + }, + children: [] + }; +} diff --git a/packages/diagram-client/test/agent-ui/panel.ts b/packages/diagram-client/test/panel-harness/panel.ts similarity index 53% rename from packages/diagram-client/test/agent-ui/panel.ts rename to packages/diagram-client/test/panel-harness/panel.ts index df26c50..b403235 100644 --- a/packages/diagram-client/test/agent-ui/panel.ts +++ b/packages/diagram-client/test/panel-harness/panel.ts @@ -1,43 +1,28 @@ /** * The shipped property panel with its window wiring skipped, its dispatcher a - * recorder, and the stock annotation editors and node sections injected. - * Shared by the agent-UI characterization tests. + * recorder, and no contributions unless a test gives it some. * * `initialize` is skipped whole: the window chrome (it needs the editor's - * page) and the document's link listener. So link handling in a rendered - * reply is not exercised here. The node sections' one-time setup (the - * discovered-skills listener), which the container runs after injection, - * runs on demand: see `listenForDiscovery`. + * page) and the document's link listener. The node sections' one-time setup, + * which the container runs after injection, runs on demand. */ import { PropertyPanel } from '../../src/property-panel'; -import { agentUiEditors, agentUiNodeSections } from '../../src/agent-ui'; -import { WorkflowShowAgentSkillsActionHandler, WorkflowShowClaudeAgentsActionHandler } from '../../src/editing-action-handlers'; import { ui } from './harness'; -/** - * A clean slate for each test: the recorders, and the static per-element - * caches of discovered skills and presets, so a test never sees what an - * earlier one discovered for the same node. - */ -export function resetAgentUi(): void { +/** A clean slate for each test. */ +export function resetPanel(): void { ui.reset(); - (WorkflowShowAgentSkillsActionHandler as any).skillsByElementId.clear(); - (WorkflowShowClaudeAgentsActionHandler as any).agentsByElementId.clear(); } export class TestPanel extends PropertyPanel { readonly dispatched: any[] = []; - private readonly windowListeners: Array<[string, EventListenerOrEventListenerObject]> = []; protected override initialize(): void { - // Skipped whole; see the class comment. + // Skipped whole; see the file comment. } constructor() { super(); - // What `agentUiModule` binds. - (this as any).contributedAnnotationEditors = agentUiEditors(); - (this as any).contributedNodeSections = agentUiNodeSections(); (this as any).actionDispatcher = { dispatch: async (action: unknown) => { this.dispatched.push(action); }, dispatchAll: async (actions: unknown[]) => { this.dispatched.push(...actions); } @@ -50,30 +35,6 @@ export class TestPanel extends PropertyPanel { this.updateContent(node); } - /** - * The re-render when discovered skills arrive (the shipped `initialize` - * installs it). Removed again by `dispose`, so a later test's panel is the - * only one drawing into the page. - */ - listenForDiscovery(): void { - const add = window.addEventListener.bind(window); - (window as any).addEventListener = (type: string, listener: EventListenerOrEventListenerObject, options?: unknown) => { - this.windowListeners.push([type, listener]); - add(type, listener, options as never); - }; - try { - this.installNodeSections(); - } finally { - (window as any).addEventListener = add; - } - } - - dispose(): void { - for (const [type, listener] of this.windowListeners.splice(0)) { - window.removeEventListener(type, listener); - } - } - /** The last operation of a kind the panel dispatched. */ last(kind: string): any { return [...this.dispatched].reverse().find(action => action?.kind === kind); diff --git a/packages/diagram-client/test/stock-feature-modules.test.ts b/packages/diagram-client/test/stock-feature-modules.test.ts index 3c8cded..4d94e74 100644 --- a/packages/diagram-client/test/stock-feature-modules.test.ts +++ b/packages/diagram-client/test/stock-feature-modules.test.ts @@ -12,8 +12,6 @@ import { composeDiagramModules, stockDiagramOptions } from '../src/container'; import { diagramBaseModule } from '../src/base.module'; import { workflowViewsModule } from '../src/stock-views.module'; import { workflowFeaturesModule } from '../src/stock-features.module'; -import { agentUiModule, agentUiEditors, agentUiTemplates, agentUiNodeSections } from '../src/agent-ui'; -import { recordModuleRegistrations, sortEntries } from './container-registration-recorder'; describe('the stock diagram, with what a product adds', () => { it('is the base, the stock views and the stock feature UI when nothing is added', () => { @@ -49,23 +47,3 @@ describe('the stock diagram, with what a product adds', () => { } }); }); - -describe('the agent workflow UI as a module of its own', () => { - it('binds the @tool and @agent editors, the agent sections, the @tool and @viewer templates and the discovery answers’ handlers', () => { - expect(recordModuleRegistrations([agentUiModule])).toEqual(sortEntries([ - { op: 'actionHandler', kind: 'dialogram.showAgentSkills', handler: 'WorkflowShowAgentSkillsActionHandler' }, - { op: 'actionHandler', kind: 'dialogram.showClaudeAgents', handler: 'WorkflowShowClaudeAgentsActionHandler' }, - { op: 'bind', id: 'Symbol(dialogram.AnnotationEditor)', to: 'const:ToolAnnotationEditor' }, - { op: 'bind', id: 'Symbol(dialogram.AnnotationEditor)', to: 'const:AgentAnnotationEditor' }, - { op: 'bind', id: 'Symbol(dialogram.NodeSectionContribution)', to: 'const:AgentNodeSections' }, - { op: 'bind', id: 'Symbol(dialogram.AnnotationTemplate)', to: 'const:ToolAnnotationTemplate' }, - { op: 'bind', id: 'Symbol(dialogram.AnnotationTemplate)', to: 'const:ViewerAnnotationTemplate' } - ])); - }); - - it('binds the editors and templates in the order the panel and the wizard show them', () => { - expect(agentUiEditors().map(e => e.annotationName)).toEqual(['tool', 'agent']); - expect(agentUiNodeSections().map(s => s.id)).toEqual(['agent']); - expect(agentUiTemplates().map(t => t.annotationName)).toEqual(['tool', 'viewer']); - }); -}); diff --git a/packages/diagram-client/test/stock-starter-build.test.ts b/packages/diagram-client/test/stock-starter-build.test.ts index c98f2da..47689c8 100644 --- a/packages/diagram-client/test/stock-starter-build.test.ts +++ b/packages/diagram-client/test/stock-starter-build.test.ts @@ -89,9 +89,4 @@ describe('the stock start-up, bundled', () => { expect(at(css, 'css/glsp-sprotty.css')).toBeLessThan(at(css, 'src/diagram-client.css')); expect(at(css, 'src/diagram-client.css')).toBeLessThan(at(css, 'stock-starter-consumer/consumer.css')); }, 120_000); - - it('builds a product’s bundle that loads the agent workflow UI through its documented path import', async () => { - const { js } = await build(path.resolve(__dirname, 'fixtures/agent-ui-consumer/entry.ts')); - expect(js).toContain('agentUiModule'); - }, 120_000); }); diff --git a/packages/diagram-server/src/operations/request-agent-skills-handler.ts b/packages/diagram-server/src/operations/request-agent-skills-handler.ts deleted file mode 100644 index a44d0f6..0000000 --- a/packages/diagram-server/src/operations/request-agent-skills-handler.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { Action as ProtocolAction } from '@eclipse-glsp/protocol'; -import { ActionDispatcher, Command, ModelState, OperationHandler } from '@eclipse-glsp/server'; -import { inject, injectable } from 'inversify'; -import { promises as fs } from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import type { Dirent } from 'node:fs'; -import { URI } from 'vscode-uri'; - -export type DiscoveredAgentSkill = { - name: string; - description?: string; - root: string; - skillDir: string; - skillMdPath: string; - hasSkillMd: boolean; - hooks: string[]; - declaredHooks?: { - pre?: string; - post?: string; - }; - missingDeclaredHooks?: string[]; - warnings?: string[]; -}; - -export namespace WorkflowRequestAgentSkillsOperation { - export const KIND = 'dialogram.requestAgentSkills'; - - export type RootsMode = 'all' | 'project'; - - export interface Operation extends ProtocolAction { - kind: typeof KIND; - elementId: string; - rootsMode?: RootsMode; - } - - export function is(action: unknown): action is Operation { - return !!action && typeof action === 'object' && (action as any).kind === KIND; - } -} - -export namespace WorkflowShowAgentSkillsAction { - export const KIND = 'dialogram.showAgentSkills'; - - export interface Action extends ProtocolAction { - kind: typeof KIND; - elementId: string; - skills: DiscoveredAgentSkill[]; - } - - export function create(options: Omit): Action { - return { kind: KIND, ...options }; - } -} - -function collectAncestorSkillRoots(runtimeRoot: string): string[] { - const roots: string[] = []; - let current = path.resolve(runtimeRoot); - while (true) { - roots.push(path.join(current, '.wf', 'skills')); - roots.push(path.join(current, '.claude', 'skills')); - const parent = path.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - return roots; -} - -class WorkflowRequestAgentSkillsCommand implements Command { - constructor( - protected readonly handler: WorkflowRequestAgentSkillsOperationHandler, - protected readonly operation: WorkflowRequestAgentSkillsOperation.Operation - ) {} - - async execute(): Promise { - await this.handler.executeLogic(this.operation); - } - - undo(): void {} - redo(): void {} -} - -@injectable() -export class WorkflowRequestAgentSkillsOperationHandler extends OperationHandler { - readonly operationType = WorkflowRequestAgentSkillsOperation.KIND; - - @inject(ModelState) - protected override modelState!: ModelState; - - @inject(ActionDispatcher) - protected readonly actionDispatcher!: ActionDispatcher; - - createCommand(operation: ProtocolAction): Command | undefined { - if (!WorkflowRequestAgentSkillsOperation.is(operation)) { - return undefined; - } - return new WorkflowRequestAgentSkillsCommand(this, operation); - } - - async executeLogic(operation: WorkflowRequestAgentSkillsOperation.Operation): Promise { - const sourceUri = this.modelState.sourceUri; - if (!sourceUri) { - return; - } - const runtimeRoot = path.dirname(URI.parse(sourceUri).fsPath); - const roots = this.skillRoots(runtimeRoot, operation.rootsMode ?? 'all'); - const skills = await this.discoverSkills(roots); - this.actionDispatcher.dispatch( - WorkflowShowAgentSkillsAction.create({ - elementId: operation.elementId, - skills - }) as any - ); - } - - protected skillRoots(runtimeRoot: string, mode: WorkflowRequestAgentSkillsOperation.RootsMode): string[] { - const cwd = process.cwd(); - const home = os.homedir(); - const explicit = String(process.env.WF_SKILLS_ROOT ?? '').trim(); - const projectRoots = [ - explicit, - ...collectAncestorSkillRoots(runtimeRoot) - ].filter(Boolean); - const extraRoots = mode === 'all' - ? [ - path.join(cwd, '.wf', 'skills'), - path.join(cwd, '.claude', 'skills'), - path.join(home, '.claude', 'skills') - ] - : []; - const roots = [...projectRoots, ...extraRoots]; - const seen = new Set(); - const unique: string[] = []; - for (const root of roots) { - const normalized = path.resolve(root); - if (seen.has(normalized)) continue; - seen.add(normalized); - unique.push(normalized); - } - return unique; - } - - protected async discoverSkills(roots: string[]): Promise { - const found = new Map(); - for (const root of roots) { - let entries: Dirent[] = []; - try { - entries = await fs.readdir(root, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.isDirectory()) continue; - const name = entry.name; - if (!name || name.includes('/') || name.includes('\\') || name.includes('..')) continue; - if (found.has(name)) continue; - const skillDir = path.join(root, name); - const skillMdPath = path.join(skillDir, 'SKILL.md'); - const hasSkillMd = await this.fileExists(skillMdPath); - const hooks = await this.discoverHooks(path.join(skillDir, 'scripts')); - let declaredHooks: { pre?: string; post?: string } | undefined; - let description: string | undefined; - const warnings: string[] = []; - if (hasSkillMd) { - try { - const raw = await fs.readFile(skillMdPath, 'utf-8'); - declaredHooks = this.parseDeclaredHooks(raw); - description = this.extractSkillDescription(raw); - } catch (err) { - warnings.push(`Failed to parse SKILL.md: ${String(err)}`); - } - } - const missingDeclaredHooks: string[] = []; - if (declaredHooks?.pre && !hooks.includes(declaredHooks.pre)) { - missingDeclaredHooks.push(declaredHooks.pre); - } - if (declaredHooks?.post && !hooks.includes(declaredHooks.post)) { - missingDeclaredHooks.push(declaredHooks.post); - } - found.set(name, { - name, - root, - skillDir, - skillMdPath, - hasSkillMd, - description, - hooks, - declaredHooks, - missingDeclaredHooks, - warnings, - }); - } - } - return [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); - } - - protected parseDeclaredHooks(skillMdText: string): { pre?: string; post?: string } | undefined { - if (!skillMdText.startsWith('---\n')) { - return undefined; - } - const end = skillMdText.indexOf('\n---\n', 4); - if (end < 0) { - return undefined; - } - const header = skillMdText.slice(4, end); - const lines = header.split(/\r?\n/); - let inHooks = false; - const result: { pre?: string; post?: string } = {}; - for (const raw of lines) { - const line = raw.replace(/\t/g, ' '); - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith('#')) { - continue; - } - if (trimmed === 'hooks:' || trimmed === 'hooks') { - inHooks = true; - continue; - } - if (!inHooks) { - continue; - } - if (!line.startsWith(' ')) { - inHooks = false; - continue; - } - const match = trimmed.match(/^(pre|post)\s*:\s*(.+)$/i); - if (!match) { - continue; - } - const key = match[1].toLowerCase() as 'pre' | 'post'; - const value = String(match[2]).trim().replace(/^['"]|['"]$/g, ''); - if (value) { - result[key] = value; - } - } - return Object.keys(result).length > 0 ? result : undefined; - } - - protected extractSkillDescription(skillMdText: string): string | undefined { - const lines = skillMdText.split(/\r?\n/); - let i = 0; - if (lines[0] === '---') { - i = 1; - while (i < lines.length && lines[i] !== '---') { - i += 1; - } - if (i < lines.length && lines[i] === '---') { - i += 1; - } - } - - for (; i < lines.length; i += 1) { - const line = lines[i].trim(); - if (!line) continue; - if (line.startsWith('#')) continue; - return line.length > 180 ? `${line.slice(0, 177)}...` : line; - } - return undefined; - } - - protected async fileExists(filePath: string): Promise { - try { - const st = await fs.stat(filePath); - return st.isFile(); - } catch { - return false; - } - } - - protected async discoverHooks(scriptsDir: string): Promise { - let entries: Dirent[] = []; - try { - entries = await fs.readdir(scriptsDir, { withFileTypes: true }); - } catch { - return []; - } - const hooks = new Set(); - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!(entry.name.endsWith('.py') || entry.name.endsWith('.sh'))) continue; - const hook = entry.name.replace(/\.(py|sh)$/i, '').trim(); - if (!hook) continue; - hooks.add(hook); - } - return [...hooks].sort((a, b) => a.localeCompare(b)); - } -} - -export const __testables = { - collectAncestorSkillRoots, - skillRoots: ( - handler: WorkflowRequestAgentSkillsOperationHandler, - runtimeRoot: string, - mode: WorkflowRequestAgentSkillsOperation.RootsMode - ) => handler['skillRoots'](runtimeRoot, mode), - parseDeclaredHooks: (handler: WorkflowRequestAgentSkillsOperationHandler, text: string) => - handler['parseDeclaredHooks'](text), - extractSkillDescription: (handler: WorkflowRequestAgentSkillsOperationHandler, text: string) => - handler['extractSkillDescription'](text) -}; diff --git a/packages/diagram-server/src/operations/request-claude-agents-handler.ts b/packages/diagram-server/src/operations/request-claude-agents-handler.ts deleted file mode 100644 index d82d9fb..0000000 --- a/packages/diagram-server/src/operations/request-claude-agents-handler.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { Action as ProtocolAction } from '@eclipse-glsp/protocol'; -import { ActionDispatcher, Command, ModelState, OperationHandler } from '@eclipse-glsp/server'; -import { inject, injectable } from 'inversify'; -import { promises as fs } from 'node:fs'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { URI } from 'vscode-uri'; - -export type DiscoveredClaudeAgentProfile = { - name: string; - description?: string; - path: string; - ignoredFields?: string[]; - warnings?: string[]; -}; - -export namespace WorkflowRequestClaudeAgentsOperation { - export const KIND = 'dialogram.requestClaudeAgents'; - - export interface Operation extends ProtocolAction { - kind: typeof KIND; - elementId: string; - } - - export function is(action: unknown): action is Operation { - return !!action && typeof action === 'object' && (action as any).kind === KIND; - } -} - -export namespace WorkflowShowClaudeAgentsAction { - export const KIND = 'dialogram.showClaudeAgents'; - - export interface Action extends ProtocolAction { - kind: typeof KIND; - elementId: string; - agents: DiscoveredClaudeAgentProfile[]; - } - - export function create(options: Omit): Action { - return { kind: KIND, ...options }; - } -} - -class WorkflowRequestClaudeAgentsCommand implements Command { - constructor( - protected readonly handler: WorkflowRequestClaudeAgentsOperationHandler, - protected readonly operation: WorkflowRequestClaudeAgentsOperation.Operation - ) {} - - async execute(): Promise { - await this.handler.executeLogic(this.operation); - } - - undo(): void {} - redo(): void {} -} - -@injectable() -export class WorkflowRequestClaudeAgentsOperationHandler extends OperationHandler { - readonly operationType = WorkflowRequestClaudeAgentsOperation.KIND; - - @inject(ModelState) - protected override modelState!: ModelState; - - @inject(ActionDispatcher) - protected readonly actionDispatcher!: ActionDispatcher; - - createCommand(operation: ProtocolAction): Command | undefined { - if (!WorkflowRequestClaudeAgentsOperation.is(operation)) { - return undefined; - } - return new WorkflowRequestClaudeAgentsCommand(this, operation); - } - - async executeLogic(operation: WorkflowRequestClaudeAgentsOperation.Operation): Promise { - const sourceUri = this.modelState.sourceUri; - if (!sourceUri) { - return; - } - const runtimeRoot = path.dirname(URI.parse(sourceUri).fsPath); - const roots = this.agentRoots(runtimeRoot); - const agents = await this.discoverClaudeAgents(roots); - this.actionDispatcher.dispatch( - WorkflowShowClaudeAgentsAction.create({ - elementId: operation.elementId, - agents - }) as any - ); - } - - protected agentRoots(runtimeRoot: string): string[] { - const explicit = String(process.env.WF_CLAUDE_AGENTS_ROOT ?? '').trim(); - const roots = [ - explicit, - path.join(runtimeRoot, '.claude', 'agents'), - path.join(process.cwd(), '.claude', 'agents'), - path.join(os.homedir(), '.claude', 'agents'), - ].filter(Boolean); - const seen = new Set(); - const unique: string[] = []; - for (const root of roots) { - const resolved = path.resolve(root); - if (seen.has(resolved)) { - continue; - } - seen.add(resolved); - unique.push(resolved); - } - return unique; - } - - protected async discoverClaudeAgents(roots: string[]): Promise { - const out = new Map(); - for (const root of roots) { - let entries: import('node:fs').Dirent[] = []; - try { - entries = await fs.readdir(root, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith('.md')) continue; - const name = entry.name.replace(/\.md$/i, '').trim(); - if (!name) continue; - if (out.has(name)) continue; - const profilePath = path.join(root, entry.name); - const raw = await fs.readFile(profilePath, 'utf-8'); - const meta = this.parseFrontmatter(raw); - const desc = this.extractDescription(raw); - const warnings = this.compatWarnings(meta); - const ignoredFields = this.ignoredFields(meta); - out.set(name, { - name, - description: desc, - path: profilePath, - ignoredFields, - warnings, - }); - } - } - return [...out.values()].sort((a, b) => a.name.localeCompare(b.name)); - } - - protected parseFrontmatter(text: string): Record { - if (!text.startsWith('---\n')) return {}; - const end = text.indexOf('\n---\n', 4); - if (end < 0) return {}; - const header = text.slice(4, end); - const meta: Record = {}; - for (const line of header.split(/\r?\n/)) { - const s = line.trim(); - if (!s || s.startsWith('#') || !s.includes(':')) continue; - const [k, v] = s.split(':', 2); - meta[k.trim()] = v.trim().replace(/^['"]|['"]$/g, ''); - } - return meta; - } - - protected extractDescription(text: string): string | undefined { - const lines = text.split(/\r?\n/); - let i = 0; - if (lines[0] === '---') { - i = 1; - while (i < lines.length && lines[i] !== '---') i += 1; - if (i < lines.length) i += 1; - } - for (; i < lines.length; i += 1) { - const s = lines[i].trim(); - if (!s || s.startsWith('#')) continue; - return s.length > 180 ? `${s.slice(0, 177)}...` : s; - } - return undefined; - } - - protected compatWarnings(meta: Record): string[] { - const warnings: string[] = []; - const ignored = [ - 'tools', 'disallowedTools', 'permissionMode', 'maxTurns', - 'mcpServers', 'hooks', 'memory', 'background', 'effort', 'isolation' - ]; - for (const key of ignored) { - if (key in meta) warnings.push(`field '${key}' is ignored in compatibility mode`); - } - if ('model' in meta) { - warnings.push("field 'model' is ignored; the runtime keeps current model/provider selection"); - } - return warnings; - } - - protected ignoredFields(meta: Record): string[] { - const ignored = [ - 'tools', 'disallowedTools', 'permissionMode', 'maxTurns', - 'mcpServers', 'hooks', 'memory', 'background', 'effort', 'isolation', - 'model' - ]; - return ignored.filter((key) => key in meta); - } -} - -export const __testables = { - parseFrontmatter: (handler: WorkflowRequestClaudeAgentsOperationHandler, text: string) => - handler['parseFrontmatter'](text), - extractDescription: (handler: WorkflowRequestClaudeAgentsOperationHandler, text: string) => - handler['extractDescription'](text), - compatWarnings: (handler: WorkflowRequestClaudeAgentsOperationHandler, meta: Record) => - handler['compatWarnings'](meta), - ignoredFields: (handler: WorkflowRequestClaudeAgentsOperationHandler, meta: Record) => - handler['ignoredFields'](meta), - agentRoots: (handler: WorkflowRequestClaudeAgentsOperationHandler, runtimeRoot: string) => - handler['agentRoots'](runtimeRoot), -}; diff --git a/packages/diagram-server/src/server/diagram-action-handlers.ts b/packages/diagram-server/src/server/diagram-action-handlers.ts index 58a5d1c..c50c367 100644 --- a/packages/diagram-server/src/server/diagram-action-handlers.ts +++ b/packages/diagram-server/src/server/diagram-action-handlers.ts @@ -501,8 +501,7 @@ export class WorkflowRequestModelActionHandler extends RequestModelActionHandler requestId.startsWith('refresh-live-overlay-') || requestId.startsWith('refresh-queue-visibility-') || requestId.startsWith('refresh-agent-ctx-') || - requestId.startsWith('refresh-caller-refs-') || - requestId.startsWith('refresh-agent-enrichment-'); + requestId.startsWith('refresh-caller-refs-'); const isAgentCtxOnly = Boolean(action.options?.agentContextOnly); this.startingSilently = isPolling; if (isPolling) { diff --git a/packages/diagram-server/src/server/diagram-module.ts b/packages/diagram-server/src/server/diagram-module.ts index edf711b..03e2252 100644 --- a/packages/diagram-server/src/server/diagram-module.ts +++ b/packages/diagram-server/src/server/diagram-module.ts @@ -47,8 +47,6 @@ import { WorkflowLabelEditValidator } from './label-edit-validator'; import { WorkflowRequestCheckEdgeActionHandler } from './request-check-edge-action-handler'; import { WorkflowRequestClipboardDataActionHandler } from './request-clipboard-data-action-handler'; import { WorkflowChangeBoundsOperationHandler } from '../operations/change-bounds-handler'; -import { WorkflowRequestAgentSkillsOperationHandler } from '../operations/request-agent-skills-handler'; -import { WorkflowRequestClaudeAgentsOperationHandler } from '../operations/request-claude-agents-handler'; import { WorkflowResetEdgeRoutesOperationHandler } from '../operations/reset-edge-routes-handler'; import { WorkflowRerouteEdgesAvoidOverlapsOperationHandler } from '../operations/reroute-edges-avoid-overlaps-handler'; import { WorkflowLayoutBoundaryFlowOperationHandler } from '../operations/layout-boundary-flow-handler'; @@ -157,10 +155,6 @@ export class WorkflowDiagramModule extends GModelDiagramModule { // Manual bendpoints / edge routing edits binding.add(WorkflowChangeRoutingPointsOperationHandler); - // Discovery handlers (no source edits) - binding.add(WorkflowRequestAgentSkillsOperationHandler); - binding.add(WorkflowRequestClaudeAgentsOperationHandler); - // Routing utilities binding.add(WorkflowLayoutBoundaryFlowOperationHandler); binding.add(WorkflowLayoutSerpentineMeshOperationHandler); diff --git a/packages/diagram-server/src/server/graph-load-perf.ts b/packages/diagram-server/src/server/graph-load-perf.ts index 4d5b32b..56e03be 100644 --- a/packages/diagram-server/src/server/graph-load-perf.ts +++ b/packages/diagram-server/src/server/graph-load-perf.ts @@ -52,13 +52,6 @@ export class GraphLoadPerf { * the first-load critical path and will arrive via a background refresh). Undefined until set. */ private callersInfo?: string; - /** - * Agent-enrichment phase outcome for the open breadcrumb: either the synchronous warm-cache - * apply cost in ms or the sentinel `'deferred'` (cold cache — the skill-status/skills/agents - * discovery was moved off the first-load critical path and will arrive via a background - * refresh). Undefined until set. Mirrors {@link callersInfo}. - */ - private enrichmentInfo?: string; constructor(private readonly label: string) { this.startedAt = this.lastAt = GraphLoadPerf.now(); } @@ -66,10 +59,6 @@ export class GraphLoadPerf { setCallers(info: number | 'deferred'): void { this.callersInfo = typeof info === 'number' ? `${Math.round(info)}ms` : info; } - /** Record the agent-enrichment (skill-status/skills/agents) phase outcome for the breadcrumb. */ - setEnrichment(info: number | 'deferred'): void { - this.enrichmentInfo = typeof info === 'number' ? `${Math.round(info)}ms` : info; - } private static now(): number { return perfNow(); } @@ -88,9 +77,8 @@ export class GraphLoadPerf { * load: `misc` is computed by the caller as `total − Σ(named buckets)`, catching the post-submit * tail plus any span whose mark name is not yet bucketed here (a newly added mark lands in * `misc` rather than vanishing). Overlay work is split into its dominant readers - * (`viewerOverlay`, `agentCtx`, `runHistory`) and the three agent-enrichment phases - * (`skillStatus`, `skills`, `agents`); `overlaysRest` is the true residual — any `ov:*` span not - * bucketed above — and should now approach 0 (enrichment is deferred off cold loads). + * (`viewerOverlay`, `agentCtx`, `runHistory`); `overlaysRest` is the true residual — any `ov:*` + * span not bucketed above — and should approach 0. */ private summarize(): { acquireMs: number; @@ -103,9 +91,6 @@ export class GraphLoadPerf { viewerOverlayMs: number; agentCtxMs: number; runHistoryMs: number; - skillStatusMs: number; - skillsMs: number; - agentsMs: number; overlaysRestMs: number; totalMs: number; } { @@ -119,9 +104,6 @@ export class GraphLoadPerf { let viewerOverlayMs = 0; let agentCtxMs = 0; let runHistoryMs = 0; - let skillStatusMs = 0; - let skillsMs = 0; - let agentsMs = 0; let overlaysRestMs = 0; for (const [name, ms] of this.spans) { if (name === 'acquire') { acquireMs += ms; } @@ -134,9 +116,6 @@ export class GraphLoadPerf { else if (name === 'ov:viewer') { viewerOverlayMs += ms; } else if (name === 'ov:agentCtx') { agentCtxMs += ms; } else if (name === 'ov:history') { runHistoryMs += ms; } - else if (name === 'ov:skillStatus') { skillStatusMs += ms; } - else if (name === 'ov:skills') { skillsMs += ms; } - else if (name === 'ov:agents') { agentsMs += ms; } else if (name.startsWith('ov:')) { overlaysRestMs += ms; } } return { @@ -150,9 +129,6 @@ export class GraphLoadPerf { viewerOverlayMs, agentCtxMs, runHistoryMs, - skillStatusMs, - skillsMs, - agentsMs, overlaysRestMs, totalMs: GraphLoadPerf.now() - this.startedAt, }; @@ -171,9 +147,6 @@ export class GraphLoadPerf { * - `viewerOverlay` latest viewer-overlay scan/apply (edges) * - `agentCtx` agent-context scan/apply (nodes) * - `runHistory` run-history enumeration (resolve run-output dirs + read run logs) - * - `skillStatus` per-node agent skill-status apply (warm-cache; deferred off cold loads) - * - `skills` agent-skills picker snapshot apply (warm-cache; deferred off cold loads) - * - `agents` Claude-agents picker snapshot apply (warm-cache; deferred off cold loads) * - `overlaysRest` residual `ov:*` not bucketed above (should approach 0) * - `misc` post-submit tail and any not-yet-bucketed span * ELK layout and edge reroute run later, in the client-bounds round-trip (see @@ -184,7 +157,7 @@ export class GraphLoadPerf { const s = this.summarize(); const accounted = s.acquireMs + s.relationshipsMs + s.transformMs + s.layoutMs + s.convertMs + s.defRangeMs + s.callerScanMs + s.viewerOverlayMs + s.agentCtxMs + s.runHistoryMs - + s.skillStatusMs + s.skillsMs + s.agentsMs + s.overlaysRestMs; + + s.overlaysRestMs; const miscMs = Math.max(0, s.totalMs - accounted); // eslint-disable-next-line no-console console.log( @@ -193,10 +166,9 @@ export class GraphLoadPerf { + `layout=${Math.round(s.layoutMs)}ms convert=${Math.round(s.convertMs)}ms ` + `defRange=${Math.round(s.defRangeMs)}ms callerScan=${Math.round(s.callerScanMs)}ms ` + `viewerOverlay=${Math.round(s.viewerOverlayMs)}ms agentCtx=${Math.round(s.agentCtxMs)}ms ` - + `runHistory=${Math.round(s.runHistoryMs)}ms skillStatus=${Math.round(s.skillStatusMs)}ms ` - + `skills=${Math.round(s.skillsMs)}ms agents=${Math.round(s.agentsMs)}ms ` + + `runHistory=${Math.round(s.runHistoryMs)}ms ` + `overlaysRest=${Math.round(s.overlaysRestMs)}ms misc=${Math.round(miscMs)}ms ` - + `callers=${this.callersInfo ?? 'n/a'} enrichment=${this.enrichmentInfo ?? 'n/a'} ` + + `callers=${this.callersInfo ?? 'n/a'} ` + `loadTotal=${Math.round(s.totalMs)}ms ` + `(nodes=${extra.nodes} edges=${extra.edges})` ); diff --git a/packages/diagram-server/src/server/source-model-storage.ts b/packages/diagram-server/src/server/source-model-storage.ts index f069e13..04f0387 100644 --- a/packages/diagram-server/src/server/source-model-storage.ts +++ b/packages/diagram-server/src/server/source-model-storage.ts @@ -35,8 +35,6 @@ import { } from '@dialogram/shared'; import { GraphGModelSource } from '../model/graph-gmodel-source'; import { tunedLayoutOptions } from '../operations/layout-tuned-handler'; -import { WorkflowRequestAgentSkillsOperationHandler, type DiscoveredAgentSkill } from '../operations/request-agent-skills-handler'; -import { WorkflowRequestClaudeAgentsOperationHandler, type DiscoveredClaudeAgentProfile } from '../operations/request-claude-agents-handler'; import { LayoutPersistenceService } from '../services/layout-persistence-service'; import { AgentStructuralEditSignal } from './agent-structural-edit-signal'; import { STORAGE_RUNTIME_OPTIONS, type StorageRuntimeOptions } from './storage-runtime-options'; @@ -165,8 +163,6 @@ function walkGEdges(element: { children?: unknown[] }, fn: (edge: GEdge) => void @injectable() export class WorkflowSourceModelStorage implements SourceModelStorage { - private readonly agentSkillDiscoveryHelper = new WorkflowRequestAgentSkillsOperationHandler(); - private readonly claudeAgentDiscoveryHelper = new WorkflowRequestClaudeAgentsOperationHandler(); private readonly workflowNodeIdentityCache = new Map | undefined>>(); /** Problems-panel collection for graph diagnostics; created lazily inside a VS Code host. */ @@ -244,34 +240,6 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { private readonly runOutputFallbackCache = new Map(); private static readonly RUN_OUTPUT_FALLBACK_TTL_MS = 30_000; - /** - * Short-TTL cache for the deferred *agent-enrichment* bundle: the skill-status map (per configured - * skill), the agent-skills picker snapshot, and the Claude-agents picker snapshot. All three scan - * `.claude`/`.wf` skill & agent roots — directories BEYOND the run-output dirs — so on a cold - * cache they inflate badly under host-boot contention (each of the many small readdir/readFile - * awaits pays scheduling latency; live evidence: a restored tab during boot spent ~21s here). None - * of the three feed first-paint geometry or the execution glow, so — mirroring the caller-reference - * deferral — a cold load skips them and one background discovery warms this cache, then a single - * refresh re-applies them synchronously. Keyed by the runtime root (`dirname(sourcePath)`, the base - * every root derivation starts from). The skill-status map only holds the skills the open graph - * referenced; {@link peekAgentEnrichment} treats a graph needing an uncached skill as a cold miss. - */ - private readonly agentEnrichmentCache = new Map; - }>(); - private static readonly AGENT_ENRICHMENT_TTL_MS = 10_000; - - /** - * In-flight de-duplication for deferred agent-enrichment discovery, keyed by runtime root. When a - * diagram opens cold and an immediate refresh (or the caller-reference refresh reload) arrives - * before the first discovery settles, both see a cold cache; this shares the single background - * discovery instead of launching parallel directory crawls. Cleared when the discovery settles. - */ - private readonly agentEnrichmentInFlight = new Map>(); - @inject(ModelState) protected modelState!: ModelState; @@ -761,13 +729,6 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { requestedRunId ); perf?.mark('ov:history'); - // Agent enrichment (skill-status on nodes + skills/agents picker snapshots on the root) is - // warm-applied here on a warm cache and deferred off the critical path on a cold cache. The - // three `ov:skillStatus`/`ov:skills`/`ov:agents` marks are recorded inside so the breadcrumb - // still lists each phase (near-0 warm, 0 when deferred). Like caller discovery, the cold-path - // scheduling is done AFTER `updateRoot` so the background completion sees the current model. - const enrichmentPlan = await this.applyAgentEnrichment(gmodelRoot, sourceUri, perf); - this.modelState.updateRoot(gmodelRoot); perf?.mark('submit'); // Now that the model is submitted and `modelState.root` is current, kick off the deferred @@ -776,13 +737,6 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { if (deferCallerDiscovery && workflowName) { this.scheduleDeferredCallerDiscovery(sourceUri, workflowFilePath, workflowName, workflowRelationshipInfo, opts); } - // Cold agent-enrichment cache: schedule one background discovery + refresh. The refresh's - // warm-cache reload re-applies the enrichment synchronously and does NOT re-defer (loop guard - // is the warm cache), so exactly one redelivery converges. Empty results warm the cache and - // dispatch nothing (subsequent polling loads stay synchronous and silent). - if (enrichmentPlan?.deferred) { - this.scheduleDeferredAgentEnrichment(sourceUri, enrichmentPlan.runtimeRoot, enrichmentPlan.neededSkillNames, opts); - } const nodeCount = Array.isArray(doc?.graph?.nodes) ? doc.graph.nodes.length : 0; const edgeCount = Array.isArray(doc?.graph?.edges) ? doc.graph.edges.length : 0; perf?.done({ nodes: nodeCount, edges: edgeCount }); @@ -894,7 +848,7 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { /** * Per-(file, workflow) re-entrancy guard for {@link scheduleDeferredCallerDiscovery}. The caller * walk is slow (it scans up to {@link MAX_SOURCE_FILES_PER_SCAN} files), so it is still cold when - * the fast, agent-context-only redeliveries (agent enrichment, or a prior caller-refs refresh) + * the fast, agent-context-only redeliveries (a prior caller-refs refresh, an agent-context refresh) * re-enter `loadSourceModel`. Each re-entry would otherwise schedule its own discovery and fire a * duplicate refresh. Holding the key while a discovery is pending collapses every re-entrant * schedule onto the first one, so exactly one redelivery ever fires per open. @@ -1523,355 +1477,6 @@ export class WorkflowSourceModelStorage implements SourceModelStorage { visit(root); } - /** - * Tracks the in-flight deferred agent-enrichment discovery so tests can await it. Not used for - * correctness in production (fire-and-forget); convergence relies on the warm cache, not this - * handle. Mirrors {@link deferredCallerDiscovery}. - */ - private deferredAgentEnrichment?: Promise; - - /** - * Warm-or-defer the three agent-enrichment overlays as one unit (skill status on nodes plus the - * skills and Claude-agents picker snapshots on the root). All three scan `.claude`/`.wf` roots — - * directories BEYOND the run-output dirs — and none of them feed first-paint geometry or the - * execution glow, so mirror the caller-reference deferral: - * - WARM cache (all needed data present + within TTL): apply all three synchronously from the - * cached bundle. Byte-identical to the old always-synchronous path. - * - COLD cache: skip them for first paint (the client degrades gracefully — it recomputes skill - * status on demand and re-primes the picker caches through the on-demand request handlers) and - * return a plan so the caller can schedule one background discovery + refresh after updateRoot. - * Records the `ov:skillStatus`/`ov:skills`/`ov:agents` marks and the `enrichment=` breadcrumb - * outcome. Returns the cold plan, or `undefined` on the warm path (nothing to schedule). - */ - private async applyAgentEnrichment( - root: GModelRoot, - sourceUri: string, - perf?: GraphLoadPerf - ): Promise<{ deferred: boolean; runtimeRoot: string; neededSkillNames: string[] } | undefined> { - const runtimeRoot = path.dirname(URI.parse(sourceUri).fsPath); - const neededSkillNames = this.collectAgentSkillNames(root); - const startedAt = perfNow(); - const warm = this.peekAgentEnrichment(runtimeRoot, neededSkillNames); - if (warm) { - this.applyAgentSkillStatusFromCache(root, warm.skillStatusByName); - perf?.mark('ov:skillStatus'); - this.applyAgentSkillsSnapshotToRoot(root, warm.skills); - perf?.mark('ov:skills'); - this.applyClaudeAgentsSnapshotToRoot(root, warm.agents); - perf?.mark('ov:agents'); - perf?.setEnrichment(perfNow() - startedAt); - return undefined; - } - // Cold: leave the enrichment args unset for first paint. Record zero-cost marks so the - // breadcrumb still lists the phases, and flag the outcome as deferred. - perf?.mark('ov:skillStatus'); - perf?.mark('ov:skills'); - perf?.mark('ov:agents'); - perf?.setEnrichment('deferred'); - return { deferred: true, runtimeRoot, neededSkillNames }; - } - - /** Collect the distinct configured skill names referenced by agent nodes in the current graph. */ - private collectAgentSkillNames(root: GModelRoot): string[] { - const names = new Set(); - const visit = (element: any): void => { - if (element instanceof GNode) { - const args = element.args as Record | undefined; - const skillName = this.getSkillNameFromAgentSpec(args?.['wf:agentSpec'] as Record | undefined); - if (skillName) { - names.add(skillName); - } - } - const children = element?.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - return [...names]; - } - - /** - * Non-computing peek at the agent-enrichment cache. Returns the cached bundle only when it is - * within TTL AND every skill the current graph needs already has a computed status (a cold miss - * otherwise, so a graph that references a not-yet-discovered skill re-defers rather than showing a - * stale/absent status). Mirrors {@link peekCallerReferences}. - */ - private peekAgentEnrichment(runtimeRoot: string, neededSkillNames: string[]): { - skills: DiscoveredAgentSkill[]; - agents: DiscoveredClaudeAgentProfile[]; - skillStatusByName: Map; - } | undefined { - const cached = this.agentEnrichmentCache.get(runtimeRoot); - if (!cached || (perfNow() - cached.at) >= WorkflowSourceModelStorage.AGENT_ENRICHMENT_TTL_MS) { - return undefined; - } - for (const name of neededSkillNames) { - if (!cached.skillStatusByName.has(name)) { - return undefined; - } - } - return cached; - } - - /** - * Synchronous warm-apply of per-node skill status from the cached status map. Sets - * `AGENT_SKILL_STATUS` on agent nodes with a configured skill and clears it on the rest — - * byte-identical to the old {@link describeAgentSkillStatus}-driven walk (the map IS that walk's - * per-skill result). The peek guarantees every needed skill is present in the map. - */ - private applyAgentSkillStatusFromCache(root: GModelRoot, skillStatusByName: Map): void { - const visit = (element: any): void => { - if (element instanceof GNode) { - const args = element.args as Record | undefined; - if (args) { - const skillName = this.getSkillNameFromAgentSpec(args['wf:agentSpec'] as Record | undefined); - if (!skillName) { - delete args[WorkflowDiagramMetadata.AGENT_SKILL_STATUS]; - } else { - args[WorkflowDiagramMetadata.AGENT_SKILL_STATUS] = skillStatusByName.get(skillName); - } - } - } - const children = element.children as any[] | undefined; - if (Array.isArray(children)) { - for (const child of children) { - visit(child); - } - } - }; - visit(root); - } - - private agentSkillRoots(runtimeRoot: string): string[] { - return this.agentSkillDiscoveryHelper['skillRoots'](runtimeRoot, 'all'); - } - - private getSkillNameFromAgentSpec(spec: Record | undefined): string { - const raw = spec?.skill; - if (typeof raw !== 'string') { - return ''; - } - return raw.trim(); - } - - private async describeAgentSkillStatus(skillName: string, roots: string[]): Promise { - const skill = skillName.trim(); - if (!skill) { - return 'No skill selected.'; - } - for (const root of roots) { - const skillDir = path.join(root, skill); - const skillMdPath = path.join(skillDir, 'SKILL.md'); - let hasSkillMd = false; - try { - hasSkillMd = (await fs.stat(skillMdPath)).isFile(); - } catch { - hasSkillMd = false; - } - if (!hasSkillMd) { - continue; - } - - const warnings: string[] = []; - let declared: { pre?: string; post?: string } | undefined; - let description: string | undefined; - try { - const raw = await fs.readFile(skillMdPath, 'utf-8'); - declared = this.agentSkillDiscoveryHelper['parseDeclaredHooks'](raw); - description = this.agentSkillDiscoveryHelper['extractSkillDescription'](raw); - } catch (err) { - warnings.push(`Failed to parse SKILL.md: ${String(err)}`); - } - - const hooks = await this.agentSkillDiscoveryHelper['discoverHooks'](path.join(skillDir, 'scripts')); - const missing: string[] = []; - if (declared?.pre && !hooks.includes(declared.pre)) { - missing.push(declared.pre); - } - if (declared?.post && !hooks.includes(declared.post)) { - missing.push(declared.post); - } - - const missingText = missing.length > 0 ? `missing scripts: ${missing.join(', ')}` : 'missing scripts: none'; - const warningText = warnings.length > 0 ? `warnings: ${warnings.join(' | ')}` : ''; - return [ - description && description.trim() !== '' ? description : `Skill '${skill}'`, - warningText, - missing.length > 0 ? missingText : '', - ].filter(Boolean).join('; '); - } - - return `Skill '${skill}' not discovered in configured roots.`; - } - - /** - * Synchronous warm-apply of the agent-skills picker snapshot onto the root args. Copies before - * sorting so the cached array is never mutated. Byte-identical output to the old - * `attachAgentSkillsSnapshot` (same sort, same `JSON.stringify`). - */ - private applyAgentSkillsSnapshotToRoot(root: GModelRoot, skills: DiscoveredAgentSkill[]): void { - const rootArgs = ((root as any).args ?? {}) as Record; - rootArgs['wf:agentSkillsSnapshot'] = JSON.stringify([...skills].sort((a, b) => a.name.localeCompare(b.name))); - (root as any).args = rootArgs; - } - - private async discoverAgentSkillsSnapshot(sourceUri: string): Promise { - const sourcePath = URI.parse(sourceUri).fsPath; - const runtimeRoot = path.dirname(sourcePath); - const roots = this.agentSkillDiscoveryHelper['skillRoots'](runtimeRoot, 'all'); - const rows = await this.agentSkillDiscoveryHelper['discoverSkills'](roots); - return rows as DiscoveredAgentSkill[]; - } - - /** - * Synchronous warm-apply of the Claude-agents picker snapshot onto the root args. Copies before - * sorting so the cached array is never mutated. Byte-identical output to the old - * `attachClaudeAgentsSnapshot`. - */ - private applyClaudeAgentsSnapshotToRoot(root: GModelRoot, agents: DiscoveredClaudeAgentProfile[]): void { - const rootArgs = ((root as any).args ?? {}) as Record; - rootArgs['wf:claudeAgentsSnapshot'] = JSON.stringify([...agents].sort((a, b) => a.name.localeCompare(b.name))); - (root as any).args = rootArgs; - } - - private async discoverClaudeAgentsSnapshot(sourceUri: string): Promise { - const sourcePath = URI.parse(sourceUri).fsPath; - const runtimeRoot = path.dirname(sourcePath); - const roots = this.claudeAgentDiscoveryHelper['agentRoots'](runtimeRoot); - const rows = await this.claudeAgentDiscoveryHelper['discoverClaudeAgents'](roots); - return rows as DiscoveredClaudeAgentProfile[]; - } - - /** Compute the skill-status string for each requested skill name (the deferred I/O). */ - private async discoverSkillStatuses(runtimeRoot: string, skillNames: string[]): Promise> { - const roots = this.agentSkillRoots(runtimeRoot); - const statuses = new Map(); - for (const skillName of skillNames) { - if (statuses.has(skillName)) { - continue; - } - statuses.set(skillName, await this.describeAgentSkillStatus(skillName, roots)); - } - return statuses; - } - - /** Run all three enrichment discoveries (skills snapshot, agents snapshot, per-skill status). */ - private async computeAgentEnrichment( - runtimeRoot: string, - sourceUri: string, - neededSkillNames: string[] - ): Promise<{ - skills: DiscoveredAgentSkill[]; - agents: DiscoveredClaudeAgentProfile[]; - skillStatusByName: Map; - }> { - const skills = await this.discoverAgentSkillsSnapshot(sourceUri); - const agents = await this.discoverClaudeAgentsSnapshot(sourceUri); - const skillStatusByName = await this.discoverSkillStatuses(runtimeRoot, neededSkillNames); - return { skills, agents, skillStatusByName }; - } - - /** Record an agent-enrichment bundle with the current timestamp; bounds the cache size. */ - private storeAgentEnrichment( - runtimeRoot: string, - bundle: { skills: DiscoveredAgentSkill[]; agents: DiscoveredClaudeAgentProfile[]; skillStatusByName: Map } - ): void { - this.agentEnrichmentCache.set(runtimeRoot, { at: perfNow(), ...bundle }); - while (this.agentEnrichmentCache.size > 64) { - const oldest = this.agentEnrichmentCache.keys().next().value; - if (oldest === undefined) { - break; - } - this.agentEnrichmentCache.delete(oldest); - } - } - - /** - * Run agent-enrichment discovery off the first-load critical path (cold-cache case), warm the - * cache, and — only if it found something AND the diagram still shows this source — deliver it via - * a single background model refresh. The refresh re-runs `loadSourceModel`, which now finds a warm - * cache and applies the enrichment synchronously; the warm branch does NOT re-schedule discovery, - * so there is no refresh loop. Empty results warm the cache and dispatch nothing. De-duplicated - * per runtime root via {@link agentEnrichmentInFlight} so an overlapping refresh reload shares the - * single discovery instead of launching a parallel directory crawl. Mirrors - * {@link scheduleDeferredCallerDiscovery}. - */ - private scheduleDeferredAgentEnrichment( - sourceUri: string, - runtimeRoot: string, - neededSkillNames: string[], - opts: Record - ): void { - if (this.agentEnrichmentInFlight.has(runtimeRoot)) { - return; - } - const run = async (): Promise => { - try { - const bundle = await this.computeAgentEnrichment(runtimeRoot, sourceUri, neededSkillNames); - this.storeAgentEnrichment(runtimeRoot, bundle); - const hasData = bundle.skills.length > 0 || bundle.agents.length > 0 || bundle.skillStatusByName.size > 0; - if (!hasData) { - return; - } - if (!this.isModelStillCurrentForSource(sourceUri)) { - return; - } - this.dispatchAgentEnrichmentRefresh(sourceUri, opts); - } catch (err) { - console.warn('[WorkflowSourceModelStorage] Deferred agent-enrichment discovery failed', err); - } finally { - this.agentEnrichmentInFlight.delete(runtimeRoot); - } - }; - const promise = run(); - this.agentEnrichmentInFlight.set(runtimeRoot, promise); - this.deferredAgentEnrichment = promise; - } - - /** - * True when the currently submitted model still corresponds to `sourceUri`, so a deferred - * agent-enrichment refresh would land on the right diagram. Enrichment is source-scoped (the - * picker snapshots are workflow-independent; per-node skill status re-derives on the warm reload), - * so — unlike caller references — only the source needs to match. - */ - private isModelStillCurrentForSource(sourceUri: string): boolean { - try { - const args = (this.modelState.root as any)?.args as Record | undefined; - return !!args && args['sourceUri'] === sourceUri; - } catch { - return false; - } - } - - /** - * Dispatch one model refresh so the just-discovered agent enrichment reaches the client. Reuses - * the polling-refresh path (the `refresh-agent-enrichment-` request-id prefix is matched in - * {@link WorkflowRequestModelActionHandler} to suppress the loading notification). No-op when no - * dispatcher is bound (headless/unit tests) — the cache is already warmed for the next load. - */ - private dispatchAgentEnrichmentRefresh(sourceUri: string, opts: Record): void { - const dispatcher = this.actionDispatcher; - if (!dispatcher) { - return; - } - const refreshAction = RequestModelAction.create({ - requestId: `refresh-agent-enrichment-${Math.round(perfNow())}`, - // Agent enrichment only rewrites node/root args (skill status + skills/agents picker - // snapshots) — no node/edge/position change — so this redelivery must run the - // agent-context-only fast path: it re-applies the overlay off the warm plan cache and - // skips the RequestBounds round-trip (and its full model re-submit / CLI re-acquire) that - // the default full path would otherwise pay on every diagram open. Without this flag the - // handler defaults `agentContextOnly` to false and reloads the whole model. - options: { ...opts, sourceUri, agentContextOnly: true } - }); - void Promise.resolve(dispatcher.dispatch(refreshAction)).catch(err => { - console.warn('[WorkflowSourceModelStorage] Failed to dispatch agent-enrichment refresh', err); - }); - } - /** * Load the agent context overlay (live or final) and attach chat history * metadata to matching agent GNodes so the property panel can display them. diff --git a/packages/diagram-server/test/agent-skill-discovery-handler.test.ts b/packages/diagram-server/test/agent-skill-discovery-handler.test.ts deleted file mode 100644 index 2791473..0000000 --- a/packages/diagram-server/test/agent-skill-discovery-handler.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __testables, WorkflowRequestAgentSkillsOperationHandler } from '../src/operations/request-agent-skills-handler.js'; - -describe('agent skill discovery hook parsing', () => { - it('parses hooks from SKILL frontmatter', () => { - const handler = new WorkflowRequestAgentSkillsOperationHandler(); - const raw = [ - '---', - 'name: writer', - 'hooks:', - ' pre: prep_context', - ' post: finalize_output', - '---', - '# Body', - ].join('\n'); - const hooks = __testables.parseDeclaredHooks(handler, raw); - expect(hooks).toEqual({ pre: 'prep_context', post: 'finalize_output' }); - }); - - it('returns undefined when no frontmatter', () => { - const handler = new WorkflowRequestAgentSkillsOperationHandler(); - const hooks = __testables.parseDeclaredHooks(handler, '# plain markdown'); - expect(hooks).toBeUndefined(); - }); - - it('extracts human description from skill body', () => { - const handler = new WorkflowRequestAgentSkillsOperationHandler(); - const raw = [ - '---', - 'name: writer', - '---', - '# Writer Skill', - '', - 'Create concise executive summaries.', - ].join('\n'); - const desc = __testables.extractSkillDescription(handler, raw); - expect(desc).toBe('Create concise executive summaries.'); - }); - - it('roots mode project excludes cwd/home fallbacks', () => { - const handler = new WorkflowRequestAgentSkillsOperationHandler(); - const runtimeRoot = '/tmp/wf-project'; - const allRoots = __testables.skillRoots(handler, runtimeRoot, 'all'); - const projectRoots = __testables.skillRoots(handler, runtimeRoot, 'project'); - - expect(projectRoots).toContain('/tmp/wf-project/.wf/skills'); - expect(projectRoots).toContain('/tmp/wf-project/.claude/skills'); - expect(projectRoots.length).toBeLessThan(allRoots.length); - - const extraInAll = allRoots.filter((root: string) => !projectRoots.includes(root)); - expect(extraInAll.some((root: string) => root.includes('/.claude/skills'))).toBe(true); - }); - - it('includes ancestor skill roots for nested workflow files', () => { - const handler = new WorkflowRequestAgentSkillsOperationHandler(); - const projectRoots = __testables.skillRoots(handler, '/tmp/wf-project/src/nested', 'project'); - - expect(projectRoots).toContain('/tmp/wf-project/src/nested/.wf/skills'); - expect(projectRoots).toContain('/tmp/wf-project/src/.wf/skills'); - expect(projectRoots).toContain('/tmp/wf-project/.wf/skills'); - }); -}); diff --git a/packages/diagram-server/test/claude-agent-discovery-handler.test.ts b/packages/diagram-server/test/claude-agent-discovery-handler.test.ts deleted file mode 100644 index 81627fb..0000000 --- a/packages/diagram-server/test/claude-agent-discovery-handler.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { __testables, WorkflowRequestClaudeAgentsOperationHandler } from '../src/operations/request-claude-agents-handler.js'; - -describe('claude agent discovery handler', () => { - it('extracts description from markdown body', () => { - const handler = new WorkflowRequestClaudeAgentsOperationHandler(); - const text = [ - '---', - 'name: reviewer', - '---', - '# Reviewer', - '', - 'Review patches for correctness and maintainability.', - ].join('\n'); - const desc = __testables.extractDescription(handler, text); - expect(desc).toBe('Review patches for correctness and maintainability.'); - }); - - it('generates compatibility warnings for unsupported fields', () => { - const handler = new WorkflowRequestClaudeAgentsOperationHandler(); - const warnings = __testables.compatWarnings(handler, { - model: 'sonnet', - tools: 'Read,Write', - permissionMode: 'default', - memory: 'project', - }); - const ignored = __testables.ignoredFields(handler, { - model: 'sonnet', - tools: 'Read,Write', - permissionMode: 'default', - memory: 'project', - }); - expect(warnings.some((w: string) => w.includes("'model'") && w.includes('ignored'))).toBe(true); - expect(warnings.some((w: string) => w.includes("'tools'") && w.includes('ignored'))).toBe(true); - expect(warnings.some((w: string) => w.includes("'permissionMode'") && w.includes('ignored'))).toBe(true); - expect(warnings.some((w: string) => w.includes("'memory'") && w.includes('ignored'))).toBe(true); - expect(ignored).toEqual(expect.arrayContaining(['model', 'tools', 'permissionMode', 'memory'])); - }); - - it('includes runtime and home claude roots', () => { - const handler = new WorkflowRequestClaudeAgentsOperationHandler(); - const roots = __testables.agentRoots(handler, '/tmp/my-workflow'); - expect(roots).toContain('/tmp/my-workflow/.claude/agents'); - expect(roots.some((r: string) => r.endsWith('/.claude/agents'))).toBe(true); - }); -}); diff --git a/packages/diagram-server/test/open-breadcrumb-runhistory-split.test.ts b/packages/diagram-server/test/open-breadcrumb-runhistory-split.test.ts index d46d0f9..ce535f6 100644 --- a/packages/diagram-server/test/open-breadcrumb-runhistory-split.test.ts +++ b/packages/diagram-server/test/open-breadcrumb-runhistory-split.test.ts @@ -20,12 +20,12 @@ function captureBreadcrumb(spans: Array<[string, number]>): string { } describe('open breadcrumb: runHistory phase split', () => { - it('reports runHistory and the enrichment phases as separate fields', () => { + it('reports runHistory as its own field', () => { // ov:skills is now its own `skills=` phase (not folded into overlaysRest), and run-history // stays distinct; overlaysRest is the residual (0 here — every span is bucketed). - const line = captureBreadcrumb([['ov:history', 111], ['ov:skills', 22]]); + const line = captureBreadcrumb([['ov:history', 111], ['ov:agentCtx', 22]]); expect(line).toContain('runHistory=111ms'); - expect(line).toContain('skills=22ms'); + expect(line).toContain('agentCtx=22ms'); expect(line).toContain('overlaysRest=0ms'); }); @@ -36,30 +36,14 @@ describe('open breadcrumb: runHistory phase split', () => { expect(line).toContain('overlaysRest=0ms'); }); - it('splits skill-status, skills, and agents into their own phases (overlaysRest stays the residual)', () => { - // Post-deferral: each enrichment phase is named on the breadcrumb and overlaysRest is the true - // residual — any ov:* span not bucketed above — which is 0 here since all spans are bucketed. - const line = captureBreadcrumb([['ov:skillStatus', 5], ['ov:skills', 7], ['ov:agents', 9]]); - expect(line).toContain('skillStatus=5ms'); - expect(line).toContain('skills=7ms'); - expect(line).toContain('agents=9ms'); - expect(line).toContain('overlaysRest=0ms'); - expect(line).toContain('runHistory=0ms'); + it('reports no agent-enrichment phases: the platform no longer discovers skills and presets', () => { + const line = captureBreadcrumb([['ov:history', 3]]); + expect(line).not.toMatch(/skillStatus=|skills=|agents=|enrichment=/); }); it('folds only unbucketed ov:* spans into overlaysRest', () => { - const line = captureBreadcrumb([['ov:skills', 4], ['ov:somethingNew', 6]]); - expect(line).toContain('skills=4ms'); + const line = captureBreadcrumb([['ov:history', 4], ['ov:somethingNew', 6]]); + expect(line).toContain('runHistory=4ms'); expect(line).toContain('overlaysRest=6ms'); }); - - it('reports the enrichment outcome field', () => { - const perf = new (GraphLoadPerf as any)('demo.py'); - perf.setEnrichment('deferred'); - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); - perf.emitOpenBreadcrumb(1, { nodes: 3, edges: 2 }); - const line = String(logSpy.mock.calls[0]?.[0] ?? ''); - logSpy.mockRestore(); - expect(line).toContain('enrichment=deferred'); - }); }); diff --git a/packages/extension/README.md b/packages/extension/README.md index 6e320ec..b545777 100644 --- a/packages/extension/README.md +++ b/packages/extension/README.md @@ -271,11 +271,10 @@ consumer's profile input — never to extend the allow-list. bundle at build time; check the extension host console to confirm the running bundles are fresh. - `[dialogram perf] load #: acquire=… layout=… loadTotal=…` — - per-load phase attribution emitted by the diagram server, including which - enrichment phases were deferred off the first paint. Loads after the first - should be low-millisecond (memoized); expensive scans (caller references, - skills/agents enrichment) run bounded in the background and redeliver via - `refresh-*` model requests. + per-load phase attribution emitted by the diagram server, including whether + the caller-reference scan was deferred off the first paint. Loads after the + first should be low-millisecond (memoized); the caller-reference scan runs + bounded in the background and redelivers via a `refresh-*` model request. ## Documentation diff --git a/packages/sidecar-toolkit/test/agent-enrichment-deferral.test.ts b/packages/sidecar-toolkit/test/agent-enrichment-deferral.test.ts deleted file mode 100644 index 880cd14..0000000 --- a/packages/sidecar-toolkit/test/agent-enrichment-deferral.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -// Tests for the cold-load deferral of agent-enrichment discovery (skill status on nodes + the -// skills/Claude-agents picker snapshots on the root). -// -// The expensive tail of a first diagram open was three directory scans over `.claude`/`.wf` skill & -// agent roots — directories BEYOND the run-output dirs. Under host-boot contention their many small -// readdir/readFile awaits inflated to seconds (live evidence: a restored tab spent ~21s here). None -// feed first-paint geometry or the execution glow, so these tests pin the new behavior: on a COLD -// cache the first model renders WITHOUT the enrichment and one background discovery delivers it -// through exactly one model refresh whose (now warm-cache) reload applies it synchronously; on a -// WARM cache it is applied synchronously with no background work; empty discovery dispatches nothing. -import { afterEach, describe, expect, it, vi } from 'vitest'; -import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; -import * as path from 'node:path'; -import { RequestModelAction } from '@eclipse-glsp/protocol'; -import { GModelRoot, GNode } from '@eclipse-glsp/server'; -import { WorkflowDiagramMetadata } from '@dialogram/shared'; -import { WorkflowSourceModelStorage } from '@dialogram/diagram-server/server/diagram-glsp-module'; -import { SidecarModelSource } from '../src/server/sidecar-model-source'; -import { type SidecarRuntimeConfig } from '../src/server/sidecar-runtime-config'; -import { NEUTRAL_CREATE_NODE_CONFIG } from './fixtures/create-node-config'; - -function testCfg(overrides: Partial = {}): SidecarRuntimeConfig { - return { - settingsNamespace: 'wfLang', - sidecarOperationPrefix: 'wfpy', - sidecarCommandSettingKey: 'wfpySidecarCommand', - sidecarCommandDefault: 'wfpy-sidecar', - cliCommandSettingKey: 'wfpyCommand', - cliCommandDefault: 'wfpy', - cliPythonModule: undefined, - operationKinds: { createEntityPort: 'op.createEntityPort', deleteEntityPort: 'op.deleteEntityPort' }, - acceptedOperationPrefixes: ['wfpy', 'calpy'], - graphAcquisition: 'cli-plan', - cliGraphArgs: (file: string) => ['plan', file, '--format', 'graph', '--best-effort'], - ...NEUTRAL_CREATE_NODE_CONFIG, - ...overrides - }; -} - -const tempDirs: string[] = []; - -async function mkTempDir(prefix: string): Promise { - const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); - tempDirs.push(dir); - return dir; -} - -async function writeFile(filePath: string, content = ''): Promise { - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, content, 'utf-8'); -} - -afterEach(async () => { - vi.restoreAllMocks(); - for (const dir of tempDirs.splice(0)) { - await fs.rm(dir, { recursive: true, force: true }); - } -}); - -/** ModelState stub exposing the writable `root` getter that `isModelStillCurrentForSource` reads. */ -function makeModelStateStub(): { set: () => void; get: () => undefined; updateRoot: (r: unknown) => void; root: unknown } { - let root: unknown; - return { - set: () => undefined, - get: () => undefined, - updateRoot: (r: unknown) => { - root = r; - }, - get root(): unknown { - return root; - } - }; -} - -/** - * Stub the run-output overlays (they read run-output dirs and are irrelevant here) and the caller - * side (empty → no caller refresh), but keep the REAL `applyAgentEnrichment` under test. The three - * enrichment discovery seams are stubbed so the machinery is exercised without touching real - * `.claude` roots. - */ -function wireStorage(storage: Record): void { - storage.applyViewerOverlayToEdges = async () => undefined; - storage.applyAgentContextToNodes = async () => undefined; - storage.attachWorkflowRunHistory = async () => undefined; - storage.getWorkflowRelationshipInfo = async () => ({ workflowNames: ['child'], entryWorkflowNames: [], callersByWorkflow: {} }); - storage.getWorkflowCallerScanRoots = async () => []; - storage.discoverSkillStatuses = async (_root: string, names: string[]) => new Map(names.map(n => [n, `status:${n}`])); -} - -/** Minimal doc for `finishLoadFromDoc` — one workflow node, no edges. */ -function makeDoc(): unknown { - return { - version: '1', - graph: { - id: 'wf:child', - nodes: [{ id: 'node:child', kind: 'workflow', label: 'child', scope: 'scope:root', ports: [] }], - edges: [], - subgraphs: [] - } - }; -} - -function newStorage(): Record { - const storage = new (WorkflowSourceModelStorage as any)(); - storage.injectedModelSource = new SidecarModelSource(testCfg()); - storage.storageOptions = { settingsNamespace: 'wfLang', operationPrefix: 'wfpy' }; - storage.modelState = makeModelStateStub(); - storage.layoutPersistence = { loadLayout: async () => undefined, loadEdgeRoutes: async () => undefined }; - wireStorage(storage); - return storage; -} - -describe('agent-enrichment deferral (cold load) and warm-cache parity', () => { - it('cold load renders without enrichment, then one background refresh reloads it with the data', async () => { - const root = await mkTempDir('wf-lang-enrich-cold-'); - const workflowFile = path.join(root, 'child.py'); - await writeFile(workflowFile, '# child workflow stub\n'); - const sourceUri = `file://${workflowFile}`; - const doc = makeDoc(); - - const storage = newStorage(); - const skills = [{ name: 'alpha', root, skillDir: `${root}/alpha`, skillMdPath: `${root}/alpha/SKILL.md`, hasSkillMd: true, hooks: [] }]; - const agents = [{ name: 'beta', path: `${root}/beta.md` }]; - storage.discoverAgentSkillsSnapshot = async () => skills; - storage.discoverClaudeAgentsSnapshot = async () => agents; - - const dispatched: RequestModelAction[] = []; - let refreshReload: Promise | undefined; - storage.actionDispatcher = { - dispatch: (action: RequestModelAction) => { - dispatched.push(action); - refreshReload = (storage as any).finishLoadFromDoc(doc, sourceUri, workflowFile, { networkName: 'child' }); - return refreshReload; - } - }; - - await (storage as any).finishLoadFromDoc(doc, sourceUri, workflowFile, { networkName: 'child' }); - - // Cold first render: enrichment args absent, discovery deferred. - const coldRoot = (storage as any).modelState.root as { args?: Record }; - expect(coldRoot.args?.['wf:agentSkillsSnapshot']).toBeUndefined(); - expect(coldRoot.args?.['wf:claudeAgentsSnapshot']).toBeUndefined(); - expect((storage as any).deferredAgentEnrichment).toBeDefined(); - - await (storage as any).deferredAgentEnrichment; - await refreshReload; - - // Exactly one refresh, addressed to this diagram, using the enrichment request-id prefix. - expect(dispatched).toHaveLength(1); - expect(dispatched[0].kind).toBe(RequestModelAction.KIND); - expect(String(dispatched[0].requestId)).toMatch(/^refresh-agent-enrichment-/); - expect(dispatched[0].options?.sourceUri).toBe(sourceUri); - // Perf guard: the redelivery must run the agent-context-only fast path (skip-bounds, warm - // plan-cache reuse) rather than the default full model reload with a fresh CLI acquire. The - // WorkflowRequestModelActionHandler keys the fast path off this flag; without it every - // diagram open pays an extra full acquire (regression this pins against). - expect(dispatched[0].options?.agentContextOnly).toBe(true); - - // The refresh's warm-cache reload now carries both snapshots. - const warmRoot = (storage as any).modelState.root as { args?: Record }; - expect(warmRoot.args?.['wf:agentSkillsSnapshot']).toBe(JSON.stringify(skills)); - expect(warmRoot.args?.['wf:claudeAgentsSnapshot']).toBe(JSON.stringify(agents)); - }); - - it('does not refresh when the deferred discovery finds nothing', async () => { - const root = await mkTempDir('wf-lang-enrich-empty-'); - const workflowFile = path.join(root, 'child.py'); - await writeFile(workflowFile, '# child workflow stub\n'); - const sourceUri = `file://${workflowFile}`; - - const storage = newStorage(); - storage.discoverAgentSkillsSnapshot = async () => []; - storage.discoverClaudeAgentsSnapshot = async () => []; - - const dispatched: RequestModelAction[] = []; - storage.actionDispatcher = { dispatch: async (a: RequestModelAction) => { dispatched.push(a); } }; - - await (storage as any).finishLoadFromDoc(makeDoc(), sourceUri, workflowFile, { networkName: 'child' }); - await (storage as any).deferredAgentEnrichment; - - expect(dispatched).toHaveLength(0); - }); - - it('warm cache applies enrichment synchronously and schedules no background work', async () => { - const root = await mkTempDir('wf-lang-enrich-warm-'); - const workflowFile = path.join(root, 'child.py'); - await writeFile(workflowFile, '# child workflow stub\n'); - const sourceUri = `file://${workflowFile}`; - - const storage = newStorage(); - // Spy the discovery seams so we can assert the warm path performs NO discovery I/O. - const skillsSpy = vi.fn(async () => [{ name: 'alpha', root, skillDir: '', skillMdPath: '', hasSkillMd: true, hooks: [] }]); - const agentsSpy = vi.fn(async () => [{ name: 'beta', path: '' }]); - storage.discoverAgentSkillsSnapshot = skillsSpy; - storage.discoverClaudeAgentsSnapshot = agentsSpy; - - const dispatched: RequestModelAction[] = []; - storage.actionDispatcher = { dispatch: async (a: RequestModelAction) => { dispatched.push(a); } }; - - // Pre-warm the cache the same way the deferred discovery would. - const runtimeRoot = path.dirname(workflowFile); - const skills = [{ name: 'alpha', root, skillDir: '', skillMdPath: '', hasSkillMd: true, hooks: [] }]; - const agents = [{ name: 'beta', path: '' }]; - (storage as any).storeAgentEnrichment(runtimeRoot, { skills, agents, skillStatusByName: new Map() }); - - await (storage as any).finishLoadFromDoc(makeDoc(), sourceUri, workflowFile, { networkName: 'child' }); - - const warmRoot = (storage as any).modelState.root as { args?: Record }; - expect(warmRoot.args?.['wf:agentSkillsSnapshot']).toBe(JSON.stringify(skills)); - expect(warmRoot.args?.['wf:claudeAgentsSnapshot']).toBe(JSON.stringify(agents)); - // Fully synchronous: no discovery, no deferral, no refresh. - expect(skillsSpy).not.toHaveBeenCalled(); - expect(agentsSpy).not.toHaveBeenCalled(); - expect((storage as any).deferredAgentEnrichment).toBeUndefined(); - expect(dispatched).toHaveLength(0); - }); -}); - -describe('agent-enrichment skill-status warm apply and subset guard', () => { - it('applies cached skill status to configured nodes and clears it on the rest', () => { - const storage = newStorage(); - const skillNode = new GNode(); - skillNode.id = 'n-skill'; - skillNode.args = { 'wf:agentSpec': { skill: 'alpha' } }; - const plainNode = new GNode(); - plainNode.id = 'n-plain'; - plainNode.args = { [WorkflowDiagramMetadata.AGENT_SKILL_STATUS]: 'stale-should-clear' }; - const root = new GModelRoot(); - root.children = [skillNode, plainNode]; - - (storage as any).applyAgentSkillStatusFromCache(root, new Map([['alpha', 'status:alpha']])); - - expect((skillNode.args as Record)[WorkflowDiagramMetadata.AGENT_SKILL_STATUS]).toBe('status:alpha'); - expect((plainNode.args as Record)[WorkflowDiagramMetadata.AGENT_SKILL_STATUS]).toBeUndefined(); - }); - - it('treats a graph needing an uncached skill as a cold miss (subset guard)', () => { - const storage = newStorage(); - const runtimeRoot = '/some/runtime/root'; - (storage as any).storeAgentEnrichment(runtimeRoot, { - skills: [], - agents: [], - skillStatusByName: new Map([['alpha', 'status:alpha']]) - }); - - // Needed skill present → warm hit. - expect((storage as any).peekAgentEnrichment(runtimeRoot, ['alpha'])).toBeDefined(); - // Needed skill absent → cold miss even though the bundle is fresh. - expect((storage as any).peekAgentEnrichment(runtimeRoot, ['gamma'])).toBeUndefined(); - }); -}); diff --git a/packages/sidecar-toolkit/test/caller-reference-deferral.test.ts b/packages/sidecar-toolkit/test/caller-reference-deferral.test.ts index d8d54d4..8890b36 100644 --- a/packages/sidecar-toolkit/test/caller-reference-deferral.test.ts +++ b/packages/sidecar-toolkit/test/caller-reference-deferral.test.ts @@ -74,7 +74,6 @@ function stubOverlaySideChannels(storage: Record): void { storage.applyViewerOverlayToEdges = async () => undefined; storage.applyAgentContextToNodes = async () => undefined; storage.attachWorkflowRunHistory = async () => undefined; - storage.applyAgentEnrichment = async () => undefined; } /** Minimal doc for `finishLoadFromDoc` — one workflow node, no edges. */ @@ -156,7 +155,7 @@ describe('caller-reference deferral (cold load) and warm-cache parity', () => { it('collapses re-entrant schedules (cold walk) onto one agent-context-only refresh', async () => { // Reproduces the drill-in regression: while the (slow) caller walk is still cold, a fast - // agent-context-only redelivery (enrichment, or a prior caller-refs refresh) re-enters and + // agent-context-only redelivery (a prior caller-refs refresh) re-enters and // re-schedules discovery. The per-key guard must collapse both onto a single redelivery. const root = await mkTempDir('wf-lang-caller-reentrant-'); const workflowFile = path.join(root, 'child.py'); diff --git a/packages/sidecar-toolkit/test/characterization-fallback.test.ts b/packages/sidecar-toolkit/test/characterization-fallback.test.ts index 5c292b9..7461ef1 100644 --- a/packages/sidecar-toolkit/test/characterization-fallback.test.ts +++ b/packages/sidecar-toolkit/test/characterization-fallback.test.ts @@ -122,7 +122,6 @@ function stubOverlaySideChannels(storage: Record): void { storage.applyViewerOverlayToEdges = async () => undefined; storage.applyAgentContextToNodes = async () => undefined; storage.attachWorkflowRunHistory = async () => undefined; - storage.applyAgentEnrichment = async () => undefined; storage.getWorkflowCallerReferences = async () => []; } diff --git a/packages/sidecar-toolkit/test/characterization-graph-load.test.ts b/packages/sidecar-toolkit/test/characterization-graph-load.test.ts index 7becff1..c93dfad 100644 --- a/packages/sidecar-toolkit/test/characterization-graph-load.test.ts +++ b/packages/sidecar-toolkit/test/characterization-graph-load.test.ts @@ -174,7 +174,6 @@ function stubOverlaySideChannels(storage: Record): void { storage.applyViewerOverlayToEdges = async () => undefined; storage.applyAgentContextToNodes = async () => undefined; storage.attachWorkflowRunHistory = async () => undefined; - storage.applyAgentEnrichment = async () => undefined; storage.getWorkflowCallerReferences = async () => []; } diff --git a/packages/sidecar-toolkit/test/cross-file-navigation-overlay.test.ts b/packages/sidecar-toolkit/test/cross-file-navigation-overlay.test.ts index 75b57be..1837122 100644 --- a/packages/sidecar-toolkit/test/cross-file-navigation-overlay.test.ts +++ b/packages/sidecar-toolkit/test/cross-file-navigation-overlay.test.ts @@ -285,7 +285,6 @@ def child(): storage.applyViewerOverlayToEdges = async () => undefined; storage.applyAgentContextToNodes = async () => undefined; storage.attachWorkflowRunHistory = async () => undefined; - storage.applyAgentEnrichment = async () => undefined; storage.getWorkflowCallerReferences = async () => []; await storage.finishLoadFromDoc( diff --git a/packages/sidecar-toolkit/test/diagnostics-parity.test.ts b/packages/sidecar-toolkit/test/diagnostics-parity.test.ts index 34f6060..329cf6e 100644 --- a/packages/sidecar-toolkit/test/diagnostics-parity.test.ts +++ b/packages/sidecar-toolkit/test/diagnostics-parity.test.ts @@ -107,7 +107,6 @@ function stubOverlaySideChannels(storage: Record): void { storage.applyViewerOverlayToEdges = async () => undefined; storage.applyAgentContextToNodes = async () => undefined; storage.attachWorkflowRunHistory = async () => undefined; - storage.applyAgentEnrichment = async () => undefined; storage.getWorkflowCallerReferences = async () => []; } diff --git a/packages/sidecar-toolkit/test/model-source-interface.test.ts b/packages/sidecar-toolkit/test/model-source-interface.test.ts index 602a876..7f6dcb8 100644 --- a/packages/sidecar-toolkit/test/model-source-interface.test.ts +++ b/packages/sidecar-toolkit/test/model-source-interface.test.ts @@ -72,7 +72,6 @@ function stubOverlaySideChannels(storage: Record): void { storage.applyViewerOverlayToEdges = async () => undefined; storage.applyAgentContextToNodes = async () => undefined; storage.attachWorkflowRunHistory = async () => undefined; - storage.applyAgentEnrichment = async () => undefined; storage.getWorkflowCallerReferences = async () => []; } diff --git a/packages/sidecar-toolkit/test/spawn-failure.test.ts b/packages/sidecar-toolkit/test/spawn-failure.test.ts index ac76690..0a09107 100644 --- a/packages/sidecar-toolkit/test/spawn-failure.test.ts +++ b/packages/sidecar-toolkit/test/spawn-failure.test.ts @@ -90,7 +90,6 @@ function makeStorage(cfg: SidecarRuntimeConfig): any { storage.applyViewerOverlayToEdges = async () => undefined; storage.applyAgentContextToNodes = async () => undefined; storage.attachWorkflowRunHistory = async () => undefined; - storage.applyAgentEnrichment = async () => undefined; storage.getWorkflowCallerReferences = async () => []; return storage; }