From 8669073048ef91254959b78cf30660e4fb772c2b Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 15 Sep 2026 11:24:46 +0200 Subject: [PATCH 1/4] ACP connectors and the run's human port The run driver forwards the profile's ACP connector and permission settings to the CLI (--acp-connector, --agent-cli-acp-permissions) and listens for the run's questions on a Unix socket it names with --elicit-socket (one JSON object a line; answer or decline back). The property panel offers `acp` as a transport, a `connector` select filled from what the runtime lists (`wfpy connectors --json`, per document through the profile's new clientBehaviorFor hook, posted to the webview as dialogram.clientBehavior.merge) and a session mode; both serialize as @agent arguments. The chat client spawns any ACP connector, not only opencode: the chat config's acpAgent hook resolves the connector setting in the runtime's listing; opencode's HTTP-only capabilities (revert, message ids) apply only where the connector has that API. The chat is the run's viewer: it renders the running agents' stream (text, reasoning, tool calls) from the same live state as the bar, and a running agent's question goes to the chat panel open on the diagram (driver -> run host -> chat runtime -> panel, chat.runQuestion / chat.runAnswer), falling back to a VS Code prompt when no panel is there. --- .../src/chat-panel-integrated.ts | 152 +++++++++++++- packages/diagram-client/src/chat-panel.css | 125 ++++++++++++ packages/diagram-client/src/profile.ts | 36 ++++ packages/diagram-client/src/property-panel.ts | 70 ++++++- .../test/chat-panel-run-question.test.ts | 64 ++++++ packages/extension-core/src/api.ts | 56 ++++++ .../src/extension/acp-client.ts | 124 ++++++++---- .../src/extension/chat/chat-runtime.ts | 62 +++++- .../src/extension/chat/glsp-chat-transport.ts | 4 + .../diagram/diagram-editor-provider.ts | 26 ++- .../src/extension/diagram/glsp-activation.ts | 17 +- .../src/extension/profile-runtime.ts | 7 +- .../extension-core/test/acp-client.test.ts | 21 +- .../test/chat-runtime-run-question.test.ts | 64 ++++++ .../sidecar-toolkit/src/acp-connectors.ts | 163 +++++++++++++++ .../sidecar-toolkit/src/cli-run-driver.ts | 176 ++++++++++++++++ packages/sidecar-toolkit/src/index.ts | 14 +- .../src/sidecar-diagram-profile.ts | 42 +++- .../test/acp-connectors.test.ts | 109 ++++++++++ .../test/cli-run-driver-elicit.test.ts | 190 ++++++++++++++++++ .../test/client-behavior-twin.test.ts | 10 +- 21 files changed, 1470 insertions(+), 62 deletions(-) create mode 100644 packages/diagram-client/test/chat-panel-run-question.test.ts create mode 100644 packages/extension-core/test/chat-runtime-run-question.test.ts create mode 100644 packages/sidecar-toolkit/src/acp-connectors.ts create mode 100644 packages/sidecar-toolkit/test/acp-connectors.test.ts create mode 100644 packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index cf93610..653e88f 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -8,6 +8,7 @@ import { repeat } from 'lit/directives/repeat.js'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { renderMarkdownSafe } from './markdown'; import { shouldStick } from './chat-scroll'; +import { RunAgentStreamActionHandler, type LiveAgentState } from './editing-action-handlers'; /** * Memoized markdown → HTML. The chat template runs `renderMarkdownSafe` for every @@ -108,7 +109,20 @@ interface PermissionItem { resolved?: 'allowed' | 'denied'; } -type TimelineItem = MessageItem | ToolItem | PermissionItem; +/** A running agent's question to the user (the run driver's human port), + * answered here: the chat is the run's viewer, not its session. */ +interface QuestionItem { + kind: 'question'; + id: number | string; + agent: string; + model?: string; + question: string; + context?: string; + choices: string[]; + resolved?: { answer?: string; declined?: boolean }; +} + +type TimelineItem = MessageItem | ToolItem | PermissionItem | QuestionItem; interface SessionEntry { id: string; @@ -239,6 +253,11 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { this.createHost(); this.createToggleButton(); this.setupMessageListener(); + // The run's agents stream into the same live state the "Running agents" + // bar renders; the panel shows them too, as a read-only viewer. + if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('dialogram.runAgents.updated', () => this.update()); + } this.update(); this.requestState(); } @@ -473,6 +492,22 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { } break; + case 'chat.runQuestion': + if (data && (typeof data.id === 'number' || typeof data.id === 'string') && typeof data.question === 'string') { + this.timeline.push({ + kind: 'question', + id: data.id, + agent: typeof data.agent === 'string' && data.agent ? data.agent : 'An agent', + model: typeof data.model === 'string' ? data.model : undefined, + question: data.question, + context: typeof data.context === 'string' && data.context ? data.context : undefined, + choices: Array.isArray(data.choices) ? data.choices.map(String) : [], + }); + this.update(); + this.autoShow('run-question'); + } + break; + case 'chat.permissionRequest': if (data?.requestId) { this.showTyping = false; @@ -843,6 +878,19 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { this.update(); } + /** Answer a running agent's question (or decline it, `answer` undefined). */ + private answerRunQuestion(item: QuestionItem, answer: string | undefined): void { + if (item.resolved) return; + if (answer === undefined) { + this.sendToHost('chat.runAnswer', { id: item.id, declined: true, reason: 'declined in the chat' }); + item.resolved = { declined: true }; + } else { + this.sendToHost('chat.runAnswer', { id: item.id, answer }); + item.resolved = { answer }; + } + this.update(); + } + private loadProviders(): void { this.sendToHost('chat.getProviders'); } @@ -1056,6 +1104,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { return ( !this.currentSessionId && this.timeline.length === 0 && + !this.hasRunView && !this.streamingText && !this.streamingThinking && !this.showTyping && @@ -1063,6 +1112,11 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { ); } + /** Whether a run's agents are (or were, this run) streaming: the viewer shows. */ + private get hasRunView(): boolean { + return RunAgentStreamActionHandler.isRunActive() || RunAgentStreamActionHandler.getAgents().length > 0; + } + /** Placeholder copy for the empty state — varies on whether sessions exist. */ private get emptyStateText(): string { return this.sessions.length === 0 @@ -1122,9 +1176,17 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { ${this.loadingLabel} ` : nothing} + ${this.runViewerTemplate()} ${repeat( this.timeline, - (item, i) => (item.kind === 'tool' ? `t${item.id}` : item.kind === 'permission' ? `p${item.requestId}` : `m${i}`), + (item, i) => + item.kind === 'tool' + ? `t${item.id}` + : item.kind === 'permission' + ? `p${item.requestId}` + : item.kind === 'question' + ? `q${item.id}` + : `m${i}`, (item) => this.itemTemplate(item) )} ${this.streamingText || this.streamingThinking ? this.streamingTemplate() : nothing} @@ -1149,8 +1211,8 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { status === 'connected' ? 'Connected' : status === 'disconnected' ? 'Disconnected' : 'Connecting…'; const statusTitle = status === 'disconnected' && this.connectionReason - ? `opencode: ${this.connectionReason}` - : 'opencode connection'; + ? `agent: ${this.connectionReason}` + : 'agent connection'; return html`
@@ -1195,7 +1257,89 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { `; } + /** + * The run's agents, read-only: the same live state (text, reasoning, tool + * calls) the "Running agents" bar folds from the run's event stream. Their + * questions arrive as `question` items below, answered from here. + */ + private runViewerTemplate(): TemplateResult | typeof nothing { + const agents = RunAgentStreamActionHandler.getAgents(); + const active = RunAgentStreamActionHandler.isRunActive(); + if (!active && agents.length === 0) return nothing; + return html` +
+
+ + Running agents + ${active ? html`live` : nothing} +
+ ${agents.length === 0 + ? html`
Waiting for agents…
` + : agents.map((a) => this.runAgentTemplate(a))} +
+ `; + } + + private runAgentTemplate(a: LiveAgentState): TemplateResult { + return html` +
+
+ + ${a.instance} + ${a.status === 'running' ? 'streaming…' : 'done'} +
+ ${a.reasoning ? this.thinkingTemplate(a.reasoning) : nothing} + ${a.toolCalls.length + ? html`
+ + ${a.toolCalls.join(', ')} +
` + : nothing} +
${a.text || (a.status === 'running' ? '…' : '')}
+
+ `; + } + private itemTemplate(item: TimelineItem): TemplateResult { + if (item.kind === 'question') { + const who = item.model ? `${item.agent} (${item.model})` : item.agent; + const readInput = (e: Event): string => + ((e.currentTarget as HTMLElement | null)?.closest('.chat-question')?.querySelector('input') as HTMLInputElement | null) + ?.value ?? ''; + return html` +
+
+ + ${who} asks +
+
${item.question}
+ ${item.context ? html`
${item.context}
` : nothing} + ${item.resolved + ? html`
+ ${item.resolved.declined ? 'Declined' : `Answered: ${item.resolved.answer}`} +
` + : item.choices.length > 0 + ? html`
+ ${item.choices.map( + (c) => html`` + )} + +
` + : html`
+ { + if (e.key === 'Enter') this.answerRunQuestion(item, (e.currentTarget as HTMLInputElement).value); + }} + /> + + +
`} +
+ `; + } + if (item.kind === 'tool') { const icon = item.status === 'completed' diff --git a/packages/diagram-client/src/chat-panel.css b/packages/diagram-client/src/chat-panel.css index 3fe20e9..f295e47 100644 --- a/packages/diagram-client/src/chat-panel.css +++ b/packages/diagram-client/src/chat-panel.css @@ -495,6 +495,131 @@ color: var(--vscode-button-secondaryForeground, #fff); } +/* ── A running agent's question (the run's human port) ── */ +.chat-question { + margin: 8px 16px; + padding: 10px 12px; + border: 1px solid var(--vscode-inputValidation-infoBorder, #007acc); + border-left-width: 3px; + border-radius: 4px; + background: color-mix(in srgb, var(--vscode-inputValidation-infoBackground, #063b49) 60%, transparent); +} +.chat-question.resolved { + opacity: 0.65; + border-color: var(--chat-border); + background: transparent; +} +.chat-question-title { + display: flex; + align-items: center; + gap: 7px; + font-size: 12px; + font-weight: 600; + margin-bottom: 6px; +} +.chat-question-text { + font-size: 12.5px; + white-space: pre-wrap; + margin-bottom: 6px; +} +.chat-question-context { + font-size: 11.5px; + opacity: 0.8; + white-space: pre-wrap; + max-height: 160px; + overflow-y: auto; + margin-bottom: 8px; +} +.chat-question-answer { + font-size: 12px; + opacity: 0.85; +} +.chat-question-input { + flex: 1 1 160px; + min-width: 120px; + padding: 3px 8px; + border-radius: 3px; + border: 1px solid var(--vscode-input-border, transparent); + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + font-size: 12px; +} + +/* ── The run's agents, as the chat's read-only viewer ── */ +.chat-run { + margin: 8px 16px; + border: 1px solid var(--chat-border); + border-radius: 4px; +} +.chat-run-head { + display: flex; + align-items: center; + gap: 7px; + padding: 6px 10px; + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + opacity: 0.8; + border-bottom: 1px solid var(--chat-border); +} +.chat-run-live { + margin-left: auto; + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + background: var(--vscode-testing-iconPassed, #3fb950); + color: var(--vscode-editor-background, #1e1e1e); + text-transform: none; + letter-spacing: 0; +} +.chat-run-empty { + padding: 8px 10px; + font-size: 12px; + opacity: 0.7; +} +.chat-run-agent { + padding: 8px 10px; + border-bottom: 1px solid var(--chat-border); +} +.chat-run-agent:last-child { + border-bottom: none; +} +.chat-run-agent-head { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + margin-bottom: 4px; +} +.chat-run-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--vscode-descriptionForeground, #8b949e); +} +.chat-run-dot.running { + background: var(--vscode-testing-iconPassed, #3fb950); +} +.chat-run-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.chat-run-status { + margin-left: auto; + font-size: 11px; + opacity: 0.7; +} +.chat-run-text { + white-space: pre-wrap; + word-break: break-word; + font-size: 12px; + line-height: 1.4; + max-height: 200px; + overflow-y: auto; +} + /* ── Composer ── */ .chat-composer-wrap { position: relative; diff --git a/packages/diagram-client/src/profile.ts b/packages/diagram-client/src/profile.ts index 1d7d7e7..1e7a2c1 100644 --- a/packages/diagram-client/src/profile.ts +++ b/packages/diagram-client/src/profile.ts @@ -24,6 +24,19 @@ type DiagramClientBehavior = { nodeFamilies?: NodeFamilySpec[]; /** Whether the host has a chat backend; derived by the platform. */ chatBackend?: boolean; + /** The ACP connectors the runtime discovered or the user declared, for an + * agent's `connector` (extension-core `AcpConnectorInfo`). */ + acpConnectors?: AcpConnectorInfo[]; +}; + +export type AcpConnectorInfo = { + name: string; + available: boolean; + source: string; + command: string; + httpApi?: boolean; + model?: string | null; + mode?: string | null; }; type DiagramIdentifier = { @@ -100,3 +113,26 @@ export function clientBehavior(): DiagramClientBehavior { const configured = getDiagramIdentifier().clientBehavior; return configured && typeof configured === 'object' ? configured : {}; } + +/** + * The host resolves part of the behavior after the webview is up (what depends + * on the machine or the workspace, e.g. the ACP connectors the runtime knows) + * and posts it as `dialogram.clientBehavior.merge`; it is folded into the + * injected identifier so every later {@link clientBehavior} call sees it. + */ +export function mergeClientBehavior(extras: Partial): void { + const g = globalThis as any; + if (!g.diagramIdentifier || typeof g.diagramIdentifier !== 'object') { + g.diagramIdentifier = {}; + } + g.diagramIdentifier.clientBehavior = { ...(g.diagramIdentifier.clientBehavior ?? {}), ...extras }; +} + +if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') { + window.addEventListener('message', (event: MessageEvent) => { + const data = event.data as { type?: string; payload?: unknown } | undefined; + if (data?.type === 'dialogram.clientBehavior.merge' && data.payload && typeof data.payload === 'object') { + mergeClientBehavior(data.payload as Partial); + } + }); +} diff --git a/packages/diagram-client/src/property-panel.ts b/packages/diagram-client/src/property-panel.ts index b3358da..9c749a7 100644 --- a/packages/diagram-client/src/property-panel.ts +++ b/packages/diagram-client/src/property-panel.ts @@ -1270,6 +1270,8 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { 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(); @@ -1290,12 +1292,14 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { 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', 'cliToolsMode', 'reasoningEffort', 'fireableWithoutInput'].includes(k) || guiOnlyKeys.has(k)) continue; + 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]); } @@ -3343,7 +3347,8 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { // 7. Backend transport. Three distinct categories — a REST API call // (http), a one-shot CLI subprocess (*-cli), or a persistent ACP protocol - // session (opencode-acp) — so they're grouped, and the CLI-only tooling + // 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'; @@ -3364,7 +3369,54 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { { value: 'max', label: 'max' } ], ''); - // CLI Tools / Reasoning apply to the CLI + ACP transports, not the HTTP API. + // 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 = this.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 = this.stripQuotesIfStringLiteral(argsMap.get('mode') ?? '').trim(); + modeInput.addEventListener('input', () => { + const val = modeInput.value.trim(); + if (val) argsMap.set('mode', this.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]) { @@ -3373,10 +3425,18 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { 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)' }, @@ -3385,6 +3445,8 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { updateBackendRelevance(this.stripQuotesIfStringLiteral(argsMap.get('transport') ?? '').trim() || 'http'); backendGrid.appendChild(transportWrap); + backendGrid.appendChild(connectorWrap); + backendGrid.appendChild(modeWrapper); backendGrid.appendChild(cliToolsWrap); backendGrid.appendChild(reasoningWrap); body.appendChild(backendGrid); @@ -3559,7 +3621,7 @@ export class PropertyPanel implements ISelectionListener, IGModelRootListener { 'prompt', 'claudeAgent', 'skill', 'model', 'provider', 'endpoint', 'timeoutMs', 'contextBudget', 'stateful', // Backend / runtime controls (dropdowns above) - 'truncationStrategy', 'fireableWithoutInput', 'transport', 'cliToolsMode', 'reasoningEffort', + 'truncationStrategy', 'fireableWithoutInput', 'transport', 'connector', 'mode', 'cliToolsMode', 'reasoningEffort', // Sub-block toggles 'useClaudeAgent', 'useSkill', 'usePrompt', 'useSkillHooks', // MCP block diff --git a/packages/diagram-client/test/chat-panel-run-question.test.ts b/packages/diagram-client/test/chat-panel-run-question.test.ts new file mode 100644 index 0000000..bc3b560 --- /dev/null +++ b/packages/diagram-client/test/chat-panel-run-question.test.ts @@ -0,0 +1,64 @@ +/** + * A running agent's question (the run driver's human port) lands in the chat + * timeline, opens the panel, and is answered back to the host as + * `chat.runAnswer`: the chat is the run's viewer here, not its session. + */ +import 'reflect-metadata'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ChatPanel } from '../src/chat-panel-integrated'; + +function makePanel() { + const panel = new ChatPanel(); + const sent: Array<{ method: string; env: any }> = []; + (panel as any).channel = { sendToHost: (method: string, env: any) => void sent.push({ method, env }) }; + const showSpy = vi.fn(); + (panel as any).show = showSpy; + return { panel, sent, showSpy }; +} + +beforeEach(() => { + (globalThis as any).requestAnimationFrame = () => 1; + (globalThis as any).cancelAnimationFrame = () => undefined; +}); +afterEach(() => { + delete (globalThis as any).requestAnimationFrame; + delete (globalThis as any).cancelAnimationFrame; +}); + +describe('a running agent\'s question in the chat', () => { + it('is recorded with its choices and opens the panel', () => { + const { panel, showSpy } = makePanel(); + (panel as any).handleIncomingMessage('chat.runQuestion', { + id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject'], timeoutMs: 60000 + }); + const timeline = (panel as any).timeline as any[]; + expect(timeline).toEqual([ + { kind: 'question', id: 7, agent: 'planner', model: 'opus', question: 'Apply the patch?', context: 'diff…', choices: ['Allow', 'Reject'] } + ]); + expect(showSpy).toHaveBeenCalled(); + }); + + it('answers and declines back to the host, once', () => { + const { panel, sent } = makePanel(); + (panel as any).handleIncomingMessage('chat.runQuestion', { id: 1, agent: 'planner', question: 'Which?', choices: ['a', 'b'] }); + (panel as any).handleIncomingMessage('chat.runQuestion', { id: 2, agent: 'planner', question: 'Free text?' }); + const [q1, q2] = (panel as any).timeline as any[]; + (panel as any).answerRunQuestion(q1, 'b'); + (panel as any).answerRunQuestion(q1, 'a'); // already answered: ignored + (panel as any).answerRunQuestion(q2, undefined); + const answers = sent.filter(s => s.env.type === 'chat.runAnswer').map(s => s.env.data); + expect(answers).toEqual([ + expect.objectContaining({ id: 1, answer: 'b' }), + expect.objectContaining({ id: 2, declined: true }) + ]); + expect(q1.resolved).toEqual({ answer: 'b' }); + expect(q2.resolved).toEqual({ declined: true }); + }); + + it('ignores a malformed question', () => { + const { panel, showSpy } = makePanel(); + (panel as any).handleIncomingMessage('chat.runQuestion', { agent: 'x' }); + expect((panel as any).timeline).toEqual([]); + expect(showSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index c05e2af..8540507 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -34,6 +34,8 @@ import type { } from "./extension/chat/slash-commands"; export type { ChatCommandContribution, ChatCommandContext, ChatCommandResult }; +import type { AcpAgentSpec } from "./extension/acp-client"; +export type { AcpAgentSpec }; /** * Semver of the API contract. Consumers must check the major version on @@ -156,6 +158,13 @@ export interface DiagramChatConfig { * the profile's registration wins (registry map semantics). */ slashCommands?: ChatCommandContribution[]; + /** + * The ACP agent the chat spawns for a workspace, resolved when the chat + * connects (a setting naming a connector, looked up in what the runtime + * lists). Undefined, or no hook, means opencode. A rejection is shown as + * the connection error. + */ + acpAgent?: (cwd: string) => Promise; } /** @@ -176,8 +185,31 @@ export interface DiagramLiveOverlaySource { * never holds the connector), the run output channel, and a hook to register the * driver's live-overlay signature source with the editor provider. */ +/** A running agent's question to the user (wfpy's elicitation over the run + * driver's socket): the toolkit's `RunQuestion`. */ +export interface DiagramRunQuestion { + id: number | string; + agent: string; + model?: string; + runId?: string; + question: string; + context?: string; + choices?: string[]; + timeoutMs?: number; +} + +export interface DiagramRunAnswer { + answer?: string; + declined?: boolean; + reason?: string; +} + export interface DiagramRunHost { overlay: ExecutionOverlaySink; + /** Puts a running agent's question to the user in the chat panel open on + * the diagram at `sourceUri`; `undefined` when no chat can take it (the + * driver then asks through a VS Code prompt). */ + askUser(question: DiagramRunQuestion, sourceUri: string): Promise; requestRefresh( sourceUri: string, kind: "full" | "agentContextOnly", @@ -211,7 +243,25 @@ export type DiagramRunDriverFactory = ( * behavior; the consumer supplies the per-product truth value. Core/client code * consults these flags instead of comparing a product-identity string. */ +/** One ACP connector as the runtime discovered or the user declared it + * (wfpy: `wfpy connectors --json`): what an agent node may name. */ +export interface AcpConnectorInfo { + name: string; + /** The connector's command resolves on the PATH. */ + available: boolean; + /** discovered | user | workspace */ + source: string; + command: string; + /** The process also serves opencode's HTTP API (revert, message ids). */ + httpApi?: boolean; + model?: string | null; + mode?: string | null; +} + export interface DiagramClientBehavior { + /** The ACP connectors the property panel offers on an agent's `connector`; + * resolved per document by {@link DiagramProfile.clientBehaviorFor}. */ + acpConnectors?: AcpConnectorInfo[]; /** Cross-file drill-down navigation resolves through the graph source model. */ graphSourceNavigation?: boolean; /** Property panel renders the network-model sections and labels. */ @@ -288,6 +338,12 @@ export interface DiagramProfile { operationKinds?: DiagramOperationKinds; /** Neutral behavior flags forwarded into the diagram webview. */ clientBehavior?: DiagramClientBehavior; + /** Behavior resolved per document once its webview is up and posted to it, + * merged over {@link clientBehavior} on the client: what depends on the + * machine or the workspace rather than on the product, such as the ACP + * connectors the runtime discovered. Best effort: a rejection or a slow + * answer leaves the static behavior as it is. */ + clientBehaviorFor?(documentUri: string): Promise>; /** Consumer-supplied webview bundle (script/style/resource-roots). When absent, * the provider serves its stock `dist/webview/*` bundle. DATA ONLY — path/URI * strings, never code objects. */ diff --git a/packages/extension-core/src/extension/acp-client.ts b/packages/extension-core/src/extension/acp-client.ts index 4bb7dae..2cc49a2 100644 --- a/packages/extension-core/src/extension/acp-client.ts +++ b/packages/extension-core/src/extension/acp-client.ts @@ -134,11 +134,33 @@ export declare interface ACPClientService { } /** - * ACP Client Service for communicating with opencode via ACP protocol. + * The agent the chat spawns: an ACP connector as the runtime lists it + * (wfpy: `wfpy connectors --json`). `argv` is the command and its arguments; + * `httpApi` says the process also serves opencode's HTTP API, which the + * client then pins to a port for the capabilities ACP does not expose + * (revert / unrevert / message ids). Without it those stay off. + */ +export interface AcpAgentSpec { + name: string; + argv: string[]; + httpApi?: boolean; +} + +const OPENCODE_AGENT: AcpAgentSpec = { + name: "opencode", + argv: ["opencode", "acp"], + httpApi: true, +}; + +/** + * ACP Client Service for communicating with an ACP agent (opencode by + * default; any connector the runtime knows, see {@link AcpAgentSpec}). * Uses TypeScript SDK exclusively (no external-process fallback or CLI mode). */ export class ACPClientService extends EventEmitter { private process: ChildProcess | null = null; + /** The agent's name, for messages: what the chat is (or is not) talking to. */ + private agentName: string = OPENCODE_AGENT.name; private connection: ClientSideConnection | null = null; private sessions: Map = new Map(); /** @@ -260,22 +282,8 @@ export class ACPClientService extends EventEmitter { command: string; env: NodeJS.ProcessEnv; } { - const home = os.homedir(); const binName = process.platform === "win32" ? "opencode.cmd" : "opencode"; - const candidateDirs = [ - path.join(home, ".opencode", "bin"), - "/usr/local/bin", - "/opt/homebrew/bin", - path.join(home, ".local", "bin"), - path.join(home, "bin"), - ]; - - const env: NodeJS.ProcessEnv = { ...process.env }; - const sep = process.platform === "win32" ? ";" : ":"; - const existingPath = env.PATH ?? env.Path ?? ""; - const merged = [...candidateDirs, existingPath].filter(Boolean).join(sep); - env.PATH = merged; - if ("Path" in env) env.Path = merged; + const { env, candidateDirs } = this.augmentedEnv(); const override = process.env.WORKFLOW_OPENCODE_PATH; if (override && existsSync(override)) { @@ -292,34 +300,72 @@ export class ACPClientService extends EventEmitter { return { command: binName, env }; } - async start(cwd: string): Promise { + /** + * The child's environment with the usual install directories ahead of the + * inherited PATH: the VS Code extension host frequently does NOT inherit the + * user's shell PATH (e.g. when launched from a GUI). + */ + private augmentedEnv(): { env: NodeJS.ProcessEnv; candidateDirs: string[] } { + const home = os.homedir(); + const candidateDirs = [ + path.join(home, ".opencode", "bin"), + "/usr/local/bin", + "/opt/homebrew/bin", + path.join(home, ".local", "bin"), + path.join(home, "bin"), + ]; + const env: NodeJS.ProcessEnv = { ...process.env }; + const sep = process.platform === "win32" ? ";" : ":"; + const existingPath = env.PATH ?? env.Path ?? ""; + const merged = [...candidateDirs, existingPath].filter(Boolean).join(sep); + env.PATH = merged; + if ("Path" in env) env.Path = merged; + return { env, candidateDirs }; + } + + /** The name of the agent this client spawned (or will spawn). */ + get agent(): string { + return this.agentName; + } + + /** + * Spawn the agent and connect. `agent` names any ACP connector; without it + * the chat talks to opencode, resolved as before. + */ + async start(cwd: string, agent?: AcpAgentSpec): Promise { if (this.isConnected) { throw new Error("ACP client is already connected"); } this.workspaceCwd = cwd; + const spec = agent && agent.argv.length > 0 ? agent : OPENCODE_AGENT; + this.agentName = spec.name; try { - // Resolve the opencode binary. The VS Code extension host frequently does - // NOT inherit the user's shell PATH (e.g. when launched from a GUI), so a - // bare 'opencode' can fail with ENOENT even though it works in a terminal. - const { command, env } = this.resolveOpencodeCommand(); - - // `opencode acp` also serves the full HTTP API. Pin it to a free port so - // we can drive HTTP-only capabilities (revert/unrevert/message ids) - // against the same process the ACP session uses. - const httpPort = await this.findFreePort(); - this.http = new OpencodeHttpClient(`http://127.0.0.1:${httpPort}`); - - // Spawn opencode acp subprocess - this.process = spawn( - command, - ["acp", "--hostname", "127.0.0.1", "--port", String(httpPort)], - { - cwd, - stdio: ["pipe", "pipe", "pipe"], - env, - }, - ); + // opencode's binary is looked for in its install locations too; any + // other agent is the command the connector names, on the augmented PATH. + const { command, env } = + spec.argv[0] === "opencode" + ? this.resolveOpencodeCommand() + : { command: spec.argv[0], env: this.augmentedEnv().env }; + const args = spec.argv.slice(1); + + // An agent with opencode's HTTP API gets it pinned to a free port, so + // the HTTP-only capabilities (revert/unrevert/message ids) drive the + // same process the ACP session uses. Without one they stay off. + if (spec.httpApi) { + const httpPort = await this.findFreePort(); + this.http = new OpencodeHttpClient(`http://127.0.0.1:${httpPort}`); + args.push("--hostname", "127.0.0.1", "--port", String(httpPort)); + } else { + this.http = null; + } + + // Spawn the agent subprocess + this.process = spawn(command, args, { + cwd, + stdio: ["pipe", "pipe", "pipe"], + env, + }); // Handle process errors this.process.on("error", (error) => { @@ -331,7 +377,7 @@ export class ACPClientService extends EventEmitter { if (code !== 0 && code !== null) { this.emit( "error", - new Error(`OpenCode process exited with code ${code}`), + new Error(`${this.agentName} process exited with code ${code}`), ); } this.handleDisconnect(); diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts index 5272ef4..c10e8a0 100644 --- a/packages/extension-core/src/extension/chat/chat-runtime.ts +++ b/packages/extension-core/src/extension/chat/chat-runtime.ts @@ -14,7 +14,8 @@ */ import * as path from "node:path"; import * as vscode from "vscode"; -import { ACPClientService, type TurnPart } from "../acp-client.js"; +import { ACPClientService, type AcpAgentSpec, type TurnPart } from "../acp-client.js"; +import type { DiagramRunAnswer, DiagramRunQuestion } from "../../api"; import { SessionManager } from "../session-manager.js"; import type { ChatMessageSink, @@ -42,6 +43,11 @@ export interface ChatRuntimeConfig { key: string; /** Human-readable name, used for the output channel (" Chat"). */ displayName: string; + /** + * The ACP agent the chat spawns for a workspace; undefined (or no hook) + * means opencode. A rejection is the connect error the panel shows. + */ + acpAgent?: (cwd: string) => Promise; /** Settings section read for `opencodePath` and `enableMcpTools`. */ settingsSection: string; /** Domain primer injected into each session's context. */ @@ -112,10 +118,18 @@ export class ChatRuntime { /** The watchdog dialog fires at most once per runtime — no per-diagram nagging. */ private connectWarned = false; + /** A running agent's question, waiting for the panel's `chat.runAnswer`. */ + private readonly pendingRunQuestions = new Map< + string, + { resolve: (answer: DiagramRunAnswer) => void; timer?: ReturnType } + >(); + constructor( context: vscode.ExtensionContext, private readonly config: ChatRuntimeConfig, private readonly postToWebview: ChatMessageSink, + /** Whether the sink can reach a panel for a URI; absent means "assume so". */ + private readonly canReach?: (uri: string) => boolean, ) { this.context = context; this.registry = new SlashCommandRegistry(config.slashCommands ?? []); @@ -226,7 +240,7 @@ export class ChatRuntime { ); void vscode.window .showWarningMessage( - `${this.config.displayName} chat could not connect to opencode within 30 seconds. ` + + `${this.config.displayName} chat could not connect to its agent (${this.acp.agent}) within 30 seconds. ` + "The agent may not be installed or on PATH.", "Show Log", "Run Diagnostics", @@ -251,8 +265,8 @@ export class ChatRuntime { private async ensureStarted(cwd: string): Promise { if (this.started) return; if (!this.startPromise) { - this.startPromise = this.acp - .start(cwd) + this.startPromise = Promise.resolve(this.config.acpAgent?.(cwd)) + .then((agent) => this.acp.start(cwd, agent)) .then(async () => { // Stand up the HTTP tool server before any session is created, // so mcpServersProvider can attach it synchronously. @@ -435,7 +449,7 @@ export class ChatRuntime { }); return true; } catch (err) { - const message = `Could not start opencode: ${String(err)}`; + const message = `Could not start the chat agent (${this.acp.agent}): ${String(err)}`; this.output.appendLine(message); this.postToWebview(uri, { type: "chat.connectionStatus", @@ -770,11 +784,49 @@ export class ChatRuntime { case "chat.permissionResponse": this.acp.respondToPermission(data.requestId, data.optionId ?? null); return; + case "chat.runAnswer": { + const pending = this.pendingRunQuestions.get(String(data?.id)); + if (!pending) return; + this.pendingRunQuestions.delete(String(data.id)); + if (pending.timer) clearTimeout(pending.timer); + pending.resolve( + data?.declined + ? { declined: true, reason: typeof data.reason === "string" ? data.reason : "declined" } + : { answer: String(data?.answer ?? "") }, + ); + return; + } default: return; } } + /** + * Put a running agent's question (the run driver's elicitation) to the chat + * panel open on `uri`, and wait for its `chat.runAnswer`. The chat is a + * viewer of the run here, not a session: nothing goes to the agent process. + * `undefined` when no panel can be reached, so the driver falls back to a + * VS Code prompt; declined on the question's own timeout. + */ + askRunQuestion(uri: string, question: DiagramRunQuestion): Promise { + if (this.canReach && !this.canReach(uri)) { + this.logLine(`run question ${question.id} from ${question.agent}: no chat panel on ${uri}`); + return Promise.resolve(undefined); + } + const key = String(question.id); + return new Promise((resolve) => { + const timer = question.timeoutMs && question.timeoutMs > 0 + ? setTimeout(() => { + if (this.pendingRunQuestions.delete(key)) { + resolve({ declined: true, reason: "timeout" }); + } + }, question.timeoutMs) + : undefined; + this.pendingRunQuestions.set(key, { resolve, timer }); + this.postToWebview(uri, { type: "chat.runQuestion", data: { ...question } }); + }); + } + /** The last model the user explicitly chose (migrated from the legacy key). */ private getPreferredModel(): string | undefined { return readStateWithFallback( diff --git a/packages/extension-core/src/extension/chat/glsp-chat-transport.ts b/packages/extension-core/src/extension/chat/glsp-chat-transport.ts index 5ba1424..b63b489 100644 --- a/packages/extension-core/src/extension/chat/glsp-chat-transport.ts +++ b/packages/extension-core/src/extension/chat/glsp-chat-transport.ts @@ -26,6 +26,9 @@ const CONNECTOR_RETRY_MAX = 20; export interface GlspChatTransport { /** Per-URI reply sink handed to the ChatRuntime constructor. */ sink: ChatMessageSink; + /** Whether a panel has spoken for `uri` on this connection, i.e. the sink + * can reach it: a question posted to nobody would wait for ever. */ + canReach(uri: string): boolean; /** Install the messenger listener and start forwarding to the runtime. */ connect(runtime: { handleMessage(uri: string, payload: ChatPayload): Promise }): void; dispose(): void; @@ -92,6 +95,7 @@ export function createGlspChatTransport(opts: { return { sink, + canReach: (uri) => Boolean(messenger && participantByUri.has(uri)), connect(runtime): void { wire(runtime); }, diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index 8ed89f9..56ee013 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -10,7 +10,7 @@ import { RequestModelAction } from '@eclipse-glsp/protocol'; import * as vscode from 'vscode'; import { statSync } from 'node:fs'; import { WORKFLOW_DIAGRAM_TYPE } from '@dialogram/shared'; -import { type DiagramProfile } from '../../api'; +import { type DiagramClientBehavior, type DiagramProfile } from '../../api'; import { normalizeSourceUriKey } from './uri-keys'; import { matchesSourceExtension, sourceWatchGlobs } from './source-extensions'; @@ -423,6 +423,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { clientId, documentUri: document.uri.toString() }); + this.postClientBehaviorExtras(document.uri.toString(), webview); } /** @@ -774,6 +775,29 @@ export class WorkflowEditorProvider extends GlspEditorProvider { * This creates a minimal HTML page that loads the GLSP diagram client * and initializes it with the diagram identifier. */ + /** + * {@link DiagramProfile.clientBehaviorFor}: the behavior that depends on + * the machine or the workspace, resolved after the webview is up (this + * setup is synchronous, and the hook may spawn the runtime) and posted to + * it as `dialogram.clientBehavior.merge`, which the client folds into its + * `clientBehavior()`. Bounded and never failing: without an answer the + * static behavior stands. + */ + private postClientBehaviorExtras(documentUri: string, webview: vscode.Webview): void { + const hook = this.profile.clientBehaviorFor; + if (!hook) { + return; + } + const deadline = new Promise>((resolve) => setTimeout(() => resolve({}), 8000)); + void Promise.race([hook.call(this.profile, documentUri), deadline]) + .then((extras) => { + if (extras && Object.keys(extras).length > 0) { + void webview.postMessage({ type: 'dialogram.clientBehavior.merge', payload: extras }); + } + }) + .catch((err) => console.warn('[dialogram provider] clientBehaviorFor failed:', err)); + } + protected getWebviewContent( webview: vscode.Webview, options: { diff --git a/packages/extension-core/src/extension/diagram/glsp-activation.ts b/packages/extension-core/src/extension/diagram/glsp-activation.ts index 46e1e7d..5d935af 100644 --- a/packages/extension-core/src/extension/diagram/glsp-activation.ts +++ b/packages/extension-core/src/extension/diagram/glsp-activation.ts @@ -37,7 +37,7 @@ import { executeViewerCommand, executeViewerOpen, executeViewerReveal } from './ import { decideDiagramOpen } from './diagram-open-decision'; import { readMcpServerUrl } from './mcp-server-url'; import { composeStorageRuntimeOptions } from './profile-storage-options'; -import { type DiagramProfile, type DiagramRunHost } from '../../api'; +import { type DiagramProfile, type DiagramRunAnswer, type DiagramRunHost, type DiagramRunQuestion } from '../../api'; // Define the diagram type constant locally to avoid import const WORKFLOW_DIAGRAM_TYPE = 'cal-network-diagram'; @@ -300,6 +300,8 @@ function serializedRangeToVscodeRange(range: SerializedRange): vscode.Range { * the helper functions that previously read/wrote module-level singletons. */ interface GlspActivationState { + /** See {@link GlspIntegrationHandle.setRunQuestionHandler}. */ + runQuestionHandler?: RunQuestionHandler; context: vscode.ExtensionContext; profile: DiagramProfile; // Transient cross-file drill-down handoff, scoped to this activation (per profile instance). @@ -332,8 +334,17 @@ export interface GlspIntegrationHandle extends vscode.Disposable { * initialize result (no stdout parsing). The chat runtime hands it to the agent clients. */ mcpServerUrl?: string; + /** + * Who answers a running agent's question for a diagram. The run driver is + * wired here, before the chat runtime exists; the profile runtime installs + * the chat as the answerer once it is up. Unset, or `undefined` back, and + * the driver falls back to a VS Code prompt. + */ + setRunQuestionHandler(handler: RunQuestionHandler | undefined): void; } +export type RunQuestionHandler = (question: DiagramRunQuestion, sourceUri: string) => Promise; + /** * Activate the GLSP integration for Workflow diagrams. * @@ -960,6 +971,9 @@ export async function activateGlspIntegration( editorProvider, executionOverlay, mcpServerUrl, + setRunQuestionHandler: (handler) => { + state.runQuestionHandler = handler; + }, dispose: () => disposable.dispose() }; } @@ -1428,6 +1442,7 @@ function registerCalDiagramCommands( // registers its source through the host so core does not hold the driver. const host: DiagramRunHost = { overlay: executionOverlay, + askUser: async (question, sourceUri) => state.runQuestionHandler?.(question, sourceUri), requestRefresh: requestRunRefresh, output: runOutput, useLiveOverlaySignatureSource: (source) => { diff --git a/packages/extension-core/src/extension/profile-runtime.ts b/packages/extension-core/src/extension/profile-runtime.ts index 0d3ee86..4675e8b 100644 --- a/packages/extension-core/src/extension/profile-runtime.ts +++ b/packages/extension-core/src/extension/profile-runtime.ts @@ -65,8 +65,12 @@ export async function activateProfileRuntime( capability, glsp.mcpServerUrl, ); - chatRuntime = new ChatRuntime(context, config, transport.sink); + chatRuntime = new ChatRuntime(context, config, transport.sink, transport.canReach); transport.connect(chatRuntime); + // The run driver's human port: a running agent's question goes to the + // chat panel open on the diagram (the chat is the run's viewer there). + const runtime = chatRuntime; + glsp.setRunQuestionHandler((question, uri) => runtime.askRunQuestion(uri, question)); context.subscriptions.push(chatRuntime, { dispose: () => { transport?.dispose(); @@ -114,6 +118,7 @@ export function assembleChatRuntimeConfig( ? (f) => capability.graphContextProvider(f) : chat.graphContextProvider, turnContextProvider: chat.turnContextProvider, + acpAgent: chat.acpAgent, selectionContext: chat.selectionContext, // The profile's own tools, plus the platform's: "what can open this file" // is a question about the EDITOR, not about any one product's graph, so it diff --git a/packages/extension-core/test/acp-client.test.ts b/packages/extension-core/test/acp-client.test.ts index 179e385..1d2f8fb 100644 --- a/packages/extension-core/test/acp-client.test.ts +++ b/packages/extension-core/test/acp-client.test.ts @@ -187,6 +187,25 @@ describe('ACPClientService', () => { expect(client.isClientConnected()).toBe(true); }); + it('spawns the given agent as its own command, without the HTTP port', async () => { + await client.start('/test/workspace', { name: 'claude', argv: ['claude-agent-acp', '--verbose'], httpApi: false }); + + expect(spawn).toHaveBeenCalledWith( + 'claude-agent-acp', + ['--verbose'], + expect.objectContaining({ + cwd: '/test/workspace', + stdio: ['pipe', 'pipe', 'pipe'], + env: expect.objectContaining({ PATH: expect.any(String) }), + }) + ); + expect(client.agent).toBe('claude'); + expect(client.isClientConnected()).toBe(true); + // The HTTP-only capabilities stay off rather than failing. + await expect(client.getMessagesWithIds('s1')).resolves.toEqual([]); + await expect(client.getRevertState('s1')).resolves.toBe(false); + }); + it('should throw error if already connected', async () => { await client.start('/test/workspace'); @@ -218,7 +237,7 @@ describe('ACPClientService', () => { expect(errorSpy).toHaveBeenCalledWith( expect.objectContaining({ - message: 'OpenCode process exited with code 1', + message: 'opencode process exited with code 1', }) ); }); diff --git a/packages/extension-core/test/chat-runtime-run-question.test.ts b/packages/extension-core/test/chat-runtime-run-question.test.ts new file mode 100644 index 0000000..4f6277d --- /dev/null +++ b/packages/extension-core/test/chat-runtime-run-question.test.ts @@ -0,0 +1,64 @@ +/** + * The run driver's human port through the chat: `askRunQuestion` posts the + * question to the panel on the diagram's URI and resolves with its + * `chat.runAnswer`; no panel there means `undefined` (the driver then asks + * through VS Code); the question's own timeout declines it. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ChatRuntime } from '../src/extension/chat/chat-runtime'; +import type { ChatPayload } from '../src/api'; + +const URI = 'file:///tmp/flow.py'; + +function makeRuntime(canReach?: (uri: string) => boolean) { + const posts: Array<{ uri: string; payload: ChatPayload }> = []; + const memento = { + get: (_key: string, defaultValue?: T): T | undefined => defaultValue, + update: async (): Promise => undefined, + keys: (): string[] => [] + }; + const context = { workspaceState: memento } as any; + const runtime = new ChatRuntime( + context, + { key: 'test', displayName: 'Test', settingsSection: 'test.chat' } as any, + (uri, payload) => posts.push({ uri, payload }), + canReach + ); + return { runtime, posts }; +} + +describe('ChatRuntime.askRunQuestion', () => { + it('posts the question and resolves with the panel\'s answer', async () => { + const { runtime, posts } = makeRuntime(() => true); + const pending = runtime.askRunQuestion(URI, { id: 3, agent: 'planner', question: 'Apply?', choices: ['Allow', 'Reject'] }); + expect(posts).toEqual([{ uri: URI, payload: { type: 'chat.runQuestion', data: { id: 3, agent: 'planner', question: 'Apply?', choices: ['Allow', 'Reject'] } } }]); + await runtime.handleMessage(URI, { type: 'chat.runAnswer', data: { id: 3, answer: 'Reject' } }); + await expect(pending).resolves.toEqual({ answer: 'Reject' }); + }); + + it('relays a decline, and ignores an answer to nothing', async () => { + const { runtime } = makeRuntime(); + const pending = runtime.askRunQuestion(URI, { id: 'q-4', agent: 'planner', question: 'Free?' }); + await runtime.handleMessage(URI, { type: 'chat.runAnswer', data: { id: 'other', answer: 'x' } }); + await runtime.handleMessage(URI, { type: 'chat.runAnswer', data: { id: 'q-4', declined: true, reason: 'no' } }); + await expect(pending).resolves.toEqual({ declined: true, reason: 'no' }); + }); + + it('is undefined when no panel can be reached on that diagram', async () => { + const { runtime, posts } = makeRuntime(() => false); + await expect(runtime.askRunQuestion(URI, { id: 1, agent: 'a', question: 'q' })).resolves.toBeUndefined(); + expect(posts).toEqual([]); + }); + + it('declines on the question\'s timeout', async () => { + vi.useFakeTimers(); + try { + const { runtime } = makeRuntime(); + const pending = runtime.askRunQuestion(URI, { id: 9, agent: 'a', question: 'q', timeoutMs: 1000 }); + vi.advanceTimersByTime(1001); + await expect(pending).resolves.toEqual({ declined: true, reason: 'timeout' }); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/packages/sidecar-toolkit/src/acp-connectors.ts b/packages/sidecar-toolkit/src/acp-connectors.ts new file mode 100644 index 0000000..cfb75eb --- /dev/null +++ b/packages/sidecar-toolkit/src/acp-connectors.ts @@ -0,0 +1,163 @@ +/** + * The ACP connectors the runtime knows: what an agent node may name, offered + * by the property panel. Asked of the CLI (`wfpy connectors --json`), which + * is the one place that knows what the machine has on its PATH and what the + * user or the workspace declared. Cached briefly per command and workspace, + * since every diagram open would otherwise spawn it. + */ +import * as path from 'node:path'; +import { runChildProcess } from './run-child-process.js'; + +export interface AcpConnectorInfo { + name: string; + available: boolean; + source: string; + command: string; + /** The process also serves opencode's HTTP API (revert, message ids). */ + httpApi: boolean; + model?: string | null; + mode?: string | null; +} + +/** What the chat spawns for a connector (extension-core `AcpAgentSpec`). */ +export interface ChatAgentSpec { + name: string; + argv: string[]; + httpApi: boolean; +} + +export interface DiscoverAcpConnectorsOptions { + cmd: string; + argsPrefix: string[]; + /** The CLI's own arguments, e.g. `['connectors', '--json', '--workspace', dir]`. */ + args: string[]; + cwd: string; + timeoutMs?: number; +} + +const CACHE_TTL_MS = 30_000; +const cache = new Map }>(); + +function parseConnectors(stdout: string): AcpConnectorInfo[] | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(stdout); + } catch { + return undefined; + } + const rows = Array.isArray(parsed) ? parsed : (parsed as { connectors?: unknown })?.connectors; + if (!Array.isArray(rows)) { + return undefined; + } + const out: AcpConnectorInfo[] = []; + for (const row of rows) { + if (!row || typeof row !== 'object' || typeof (row as { name?: unknown }).name !== 'string') { + continue; + } + const r = row as Record; + out.push({ + name: String(r.name), + available: Boolean(r.available), + source: typeof r.source === 'string' ? r.source : 'discovered', + command: typeof r.command === 'string' ? r.command : '', + httpApi: Boolean(r.http_api ?? r.httpApi), + model: typeof r.model === 'string' ? r.model : null, + mode: typeof r.mode === 'string' ? r.mode : null + }); + } + return out; +} + +/** Run the CLI's connector listing and parse it; `undefined` when the CLI has + * no such command, fails, or times out, so a caller falls back to nothing. */ +export async function discoverAcpConnectors(options: DiscoverAcpConnectorsOptions): Promise { + const key = JSON.stringify([options.cmd, options.argsPrefix, options.args, options.cwd]); + const cached = cache.get(key); + if (cached && Date.now() - cached.at < CACHE_TTL_MS) { + return cached.value; + } + const value = (async () => { + const result = await runChildProcess(options.cmd, [...options.argsPrefix, ...options.args], { + cwd: options.cwd, + timeoutMs: options.timeoutMs ?? 8000 + }); + if (result.spawnError || result.timedOut || result.code !== 0) { + return undefined; + } + return parseConnectors(result.stdout); + })(); + cache.set(key, { at: Date.now(), value }); + return value; +} + +/** + * Split a connector's command line the way the runtime does (shlex): words on + * whitespace, quotes grouping, a backslash escaping. The commands are short + * (`opencode acp`, `gemini --experimental-acp`), so this is all they need. + */ +export function splitCommand(command: string): string[] { + const out: string[] = []; + let cur = ''; + let quote: string | undefined; + let has = false; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (quote) { + if (ch === quote) quote = undefined; + else if (ch === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i]; + else cur += ch; + } else if (ch === '"' || ch === "'") { + quote = ch; + has = true; + } else if (ch === '\\' && i + 1 < command.length) { + cur += command[++i]; + has = true; + } else if (/\s/.test(ch)) { + if (has) out.push(cur); + cur = ''; + has = false; + } else { + cur += ch; + has = true; + } + } + if (has) out.push(cur); + return out; +} + +/** + * The agent the chat spawns for the connector a setting names. The listing is + * the runtime's (`discoverAcpConnectors`); `undefined` there means the runtime + * cannot list connectors, which only `opencode` survives (the chat's own + * default). Throws with the reason a reader can act on. + */ +export function resolveChatAgent(name: string, connectors: AcpConnectorInfo[] | undefined): ChatAgentSpec | undefined { + const wanted = name.trim() || 'opencode'; + if (!connectors) { + if (wanted === 'opencode') return undefined; + throw new Error(`the runtime lists no ACP connectors, so "${wanted}" cannot be resolved (only opencode works without a listing)`); + } + const found = connectors.find((c) => c.name === wanted); + if (!found) { + const known = connectors.map((c) => c.name).join(', ') || 'none'; + throw new Error(`ACP connector "${wanted}" is not known to the runtime (known: ${known}); declare it in the connectors file or pick another`); + } + if (!found.available) { + throw new Error(`ACP connector "${wanted}" is not available: "${found.command}" is not on the PATH`); + } + const argv = splitCommand(found.command); + if (argv.length === 0) { + throw new Error(`ACP connector "${wanted}" has an empty command`); + } + return { name: found.name, argv, httpApi: found.httpApi }; +} + +/** For tests and for a settings change: forget what was discovered. */ +export function resetAcpConnectorsCache(): void { + cache.clear(); +} + +/** The directory the listing is asked for: the source file's. */ +export function workspaceDirFor(sourcePath: string): string { + return path.dirname(sourcePath); +} diff --git a/packages/sidecar-toolkit/src/cli-run-driver.ts b/packages/sidecar-toolkit/src/cli-run-driver.ts index 925efaf..ada3125 100644 --- a/packages/sidecar-toolkit/src/cli-run-driver.ts +++ b/packages/sidecar-toolkit/src/cli-run-driver.ts @@ -29,6 +29,10 @@ */ import * as vscode from 'vscode'; import * as cp from 'node:child_process'; +import { unlinkSync } from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as readline from 'node:readline'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import type { ExecutionOverlaySink } from '@dialogram/shared'; @@ -86,6 +90,17 @@ export interface CliRunDriverConfig { agentToolTimeoutMsSettingKey: string; agentToolRegistrySettingKey: string; agentMcpBridgeCmdSettingKey: string; + /** The ACP connector a run's agents spawn when they name none (wfpy's + * `--acp-connector`, `wfpy connectors` lists them) and what their permission + * requests get when no user answers (`--agent-cli-acp-permissions`); both + * optional, a shell without them passes nothing. */ + acpConnectorSettingKey?: string; + acpPermissionsSettingKey?: string; + /** Host the run's questions on a Unix socket (`--elicit-socket`): an + * agent's `ask_user` question or, over ACP, a permission request reaches + * {@link CliRunDriverHost.askUser}, or a VS Code prompt when the host has + * none. Default true; never on Windows, where wfpy's side is AF_UNIX. */ + elicitSocket?: boolean; runWorkflowCommandId: string; stopWorkflowCommandId: string; /** The two non-run config command ids the driver registers to mutate/read @@ -101,9 +116,36 @@ export interface CliRunDriverConfig { }; } +/** A question a running agent puts to the user (wfpy's elicitation channel, + * one JSON object a line on the socket; `SocketElicitationHandler` in wfpy + * documents the wire format). A permission request over ACP arrives as one: + * the tool call's title as the question, the agent's options as the choices. */ +export interface RunQuestion { + id: number; + agent: string; + model?: string; + runId?: string; + question: string; + context?: string | null; + choices?: string[] | null; + timeoutMs?: number; +} + +/** The user's answer: one of the choices (or free text), or a refusal. */ +export interface RunAnswer { + answer?: string; + declined?: boolean; + reason?: string; +} + export interface CliRunDriverHost { /** Neutral execution-overlay sink; the SSE flush publishes batches here. */ overlay: ExecutionOverlaySink; + /** Answers a running agent's question for the diagram at `sourceUri`. + * Optional, and may resolve `undefined` (no one there to ask, e.g. no chat + * open on that diagram): then, as without it, the driver asks through a + * VS Code quick pick (choices) or input box (free text). */ + askUser?(question: RunQuestion, sourceUri: string): Promise; /** Core-owned diagram refresh (the driver must not hold the connector). * Core builds the exact `RequestModelAction` for `kind` — request-id prefix * `refresh-during-run-*` (full) / `refresh-agent-ctx-*` (agentContextOnly) — @@ -418,6 +460,116 @@ export class CliRunDriver { return undefined; } + private elicitSourceUri: string | undefined; + private elicitServer: net.Server | undefined; + private elicitSocketPath: string | undefined; + + /** Listen for the run's questions on a fresh Unix socket; returns its path + * for `--elicit-socket`. One connection per run, one question at a time + * (wfpy serializes them); each is answered on the same connection as one + * JSON line, `{id, answer}` or `{id, declined, reason}`. */ + private async startElicitSocket(sourceUri: string): Promise { + this.stopElicitSocket(); + this.elicitSourceUri = sourceUri; + // AF_UNIX paths are short (108 bytes on Linux): the tmp dir, not the run dir. + const socketPath = path.join(os.tmpdir(), `wfpy-elicit-${process.pid}-${Date.now().toString(36)}.sock`); + const server = net.createServer((conn) => { + conn.setEncoding('utf8'); + const lines = readline.createInterface({ input: conn }); + let chain: Promise = Promise.resolve(); + lines.on('line', (line) => { + chain = chain.then(async () => { + let question: RunQuestion; + try { + question = JSON.parse(line) as RunQuestion; + } catch { + this.host.output.appendLine(`[wf-lang ask] not JSON: ${line.slice(0, 200)}`); + return; + } + if ((question as { type?: string }).type !== 'question' && typeof question.question !== 'string') { + return; + } + const answer = await this.answerQuestion(question); + const reply = answer.declined || answer.answer === undefined + ? { id: question.id, declined: true, reason: answer.reason ?? 'declined' } + : { id: question.id, answer: answer.answer }; + if (!conn.destroyed) { + conn.write(JSON.stringify(reply) + '\n'); + } + }); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }).catch((err: unknown) => { + this.host.output.appendLine(`[wf-lang ask] no socket: ${err instanceof Error ? err.message : String(err)}`); + return undefined; + }); + if (!server.listening) { + return undefined; + } + this.elicitServer = server; + this.elicitSocketPath = socketPath; + return socketPath; + } + + private stopElicitSocket(): void { + const server = this.elicitServer; + const socketPath = this.elicitSocketPath; + this.elicitServer = undefined; + this.elicitSocketPath = undefined; + if (server) { + server.close(); + } + if (socketPath) { + try { + unlinkSync(socketPath); + } catch { + // already gone + } + } + } + + /** The host's `askUser`, else a VS Code prompt: a quick pick over the + * choices, an input box otherwise; dismissing either declines. */ + private async answerQuestion(question: RunQuestion): Promise { + const who = question.agent ? `Agent ${question.agent}` : 'An agent'; + this.host.output.appendLine(`[wf-lang ask] ${who}: ${question.question}` + + (question.choices?.length ? ` [${question.choices.join(' | ')}]` : '')); + let answer: RunAnswer | undefined; + try { + if (this.host.askUser) { + answer = await this.host.askUser(question, this.elicitSourceUri ?? ''); + } + if (answer) { + // answered by the host + } else if (question.choices && question.choices.length > 0) { + const picked = await vscode.window.showQuickPick(question.choices, { + title: `${who} asks`, + placeHolder: question.question, + ignoreFocusOut: true + }); + answer = picked === undefined ? { declined: true, reason: 'dismissed' } : { answer: picked }; + } else { + const typed = await vscode.window.showInputBox({ + title: `${who} asks`, + prompt: question.question, + placeHolder: question.context ?? undefined, + ignoreFocusOut: true + }); + answer = typed === undefined ? { declined: true, reason: 'dismissed' } : { answer: typed }; + } + } catch (err: unknown) { + answer = { declined: true, reason: err instanceof Error ? err.message : String(err) }; + } + this.host.output.appendLine(`[wf-lang ask] -> ${answer.declined ? `declined (${answer.reason ?? ''})` : answer.answer}`); + return answer; + } + private async resolveCliInvocation(forUri: vscode.Uri, isPython: boolean): Promise<{ cmd: string; argsPrefix: string[]; cwd: string } | undefined> { const startDir = await this.resolveRunRoot(forUri); if (!startDir) { @@ -648,6 +800,29 @@ export class CliRunDriver { cliArgs.push('--agent-mcp-bridge-cmd', agentMcpBridgeCmd); } + // The ACP connector and the permission policy, from the shell's settings. + if (this.config.acpConnectorSettingKey) { + const connector = (wfConfig.get(this.config.acpConnectorSettingKey, '') ?? '').trim(); + if (connector) { + cliArgs.push('--acp-connector', connector); + } + } + if (this.config.acpPermissionsSettingKey) { + const permissions = (wfConfig.get(this.config.acpPermissionsSettingKey, '') ?? '').trim(); + if (permissions) { + cliArgs.push('--agent-cli-acp-permissions', permissions); + } + } + + // The human port: the run's questions come in on a Unix socket this + // driver listens on, and go to the host's `askUser` or a VS Code prompt. + const elicitSocketPath = this.config.elicitSocket !== false && process.platform !== 'win32' + ? await this.startElicitSocket(sourceUri.toString()) + : undefined; + if (elicitSocketPath) { + cliArgs.push('--elicit-socket', elicitSocketPath); + } + const title = workflowName ? `Running workflow ${workflowName}` : 'Running workflow'; const liveGlowEnabled = vscode.workspace .getConfiguration(this.config.settingsNamespace, sourceUri) @@ -771,6 +946,7 @@ export class CliRunDriver { flushStreamEvents(); streamClient?.stop(); streamClient = undefined; + this.stopElicitSocket(); }; const exitCode = await vscode.window.withProgress( diff --git a/packages/sidecar-toolkit/src/index.ts b/packages/sidecar-toolkit/src/index.ts index c71aaaf..f562035 100644 --- a/packages/sidecar-toolkit/src/index.ts +++ b/packages/sidecar-toolkit/src/index.ts @@ -52,8 +52,20 @@ export { CliRunDriver, type CliRunDriverConfig, type CliRunDriverHost, - type AgentToolEntitySettings + type AgentToolEntitySettings, + type RunQuestion, + type RunAnswer } from './cli-run-driver.js'; +export { + discoverAcpConnectors, + resetAcpConnectorsCache, + resolveChatAgent, + splitCommand, + workspaceDirFor, + type AcpConnectorInfo, + type ChatAgentSpec, + type DiscoverAcpConnectorsOptions +} from './acp-connectors.js'; export { extractDecoratedDefinitionNames, diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index 9831a82..aeebed3 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -13,9 +13,10 @@ */ import type { EntityPaletteItemSpec, NodeFamilySpec } from '@dialogram/shared'; import * as vscode from 'vscode'; +import { discoverAcpConnectors, resolveChatAgent, workspaceDirFor } from './acp-connectors.js'; import { invokeSidecarOp } from './sidecar-graph-export.js'; import { createRegistryChatTools } from './registry-tools.js'; -import { +import { getCliInvocation, getSidecarCommand, type SidecarRuntimeConfig, type CreateNodeStrings, @@ -173,6 +174,14 @@ export interface SidecarProfileInput { agentToolTimeoutMsSettingKey: string; agentToolRegistrySettingKey: string; agentMcpBridgeCmdSettingKey: string; + /** Optional: the ACP connector and permission-policy settings the run + * driver forwards to `wfpy run` (`--acp-connector`, `--agent-cli-acp-permissions`). */ + acpConnectorSettingKey?: string; + acpPermissionsSettingKey?: string; + /** The CLI's arguments listing the ACP connectors as JSON for a workspace + * directory (wfpy: `['connectors', '--json', '--workspace', dir]`); when + * given, the property panel offers them on an agent's `connector`. */ + acpConnectorsArgs?: (workspaceDir: string) => string[]; // Chat carry-overs. chat: { @@ -308,6 +317,8 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { agentToolTimeoutMsSettingKey: input.agentToolTimeoutMsSettingKey, agentToolRegistrySettingKey: input.agentToolRegistrySettingKey, agentMcpBridgeCmdSettingKey: input.agentMcpBridgeCmdSettingKey, + acpConnectorSettingKey: input.acpConnectorSettingKey, + acpPermissionsSettingKey: input.acpPermissionsSettingKey, runWorkflowCommandId: input.commands.runWorkflow, stopWorkflowCommandId: input.commands.stopWorkflow, agentToolConfigCommands: { @@ -373,6 +384,17 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { operationKinds: input.operationKinds, clientBehavior: input.clientBehavior, edits: { operationModules: createSidecarOperationModules(runtimeConfig) }, + clientBehaviorFor: input.acpConnectorsArgs + ? async (documentUri: string) => { + const uri = vscode.Uri.parse(documentUri); + const { cmd, argsPrefix } = getCliInvocation(runtimeConfig, vscode, uri); + const dir = workspaceDirFor(uri.fsPath); + const acpConnectors = await discoverAcpConnectors({ + cmd, argsPrefix, args: input.acpConnectorsArgs!(dir), cwd: dir + }); + return acpConnectors ? { acpConnectors } : {}; + } + : undefined, modelSource: () => createSidecarModelSource(runtimeConfig), serverModules: [createSidecarServerModule(runtimeConfig)], storageOptions: { @@ -400,7 +422,23 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { // Read-only sidecar-registry tools bridged to GLSP-MCP by the platform adapter. tools: chatTools, // The libcst edit backend rewrites Python source; agents get the file as text/x-python. - sourceMimeType: 'text/x-python' + sourceMimeType: 'text/x-python', + // The chat's agent: the connector the `.` + // setting names (user level, workspace override), resolved in what + // the CLI lists for that workspace. Without both inputs the chat + // keeps its opencode default. + acpAgent: input.acpConnectorSettingKey && input.acpConnectorsArgs + ? async (cwd: string) => { + const scope = vscode.Uri.file(cwd); + const name = (vscode.workspace.getConfiguration(input.settingsNamespace, scope) + .get(input.acpConnectorSettingKey!, '') ?? '').trim(); + const { cmd, argsPrefix } = getCliInvocation(runtimeConfig, vscode, scope); + const connectors = await discoverAcpConnectors({ + cmd, argsPrefix, args: input.acpConnectorsArgs!(cwd), cwd + }); + return resolveChatAgent(name, connectors); + } + : undefined }, runDriver, newSourceFile diff --git a/packages/sidecar-toolkit/test/acp-connectors.test.ts b/packages/sidecar-toolkit/test/acp-connectors.test.ts new file mode 100644 index 0000000..f003503 --- /dev/null +++ b/packages/sidecar-toolkit/test/acp-connectors.test.ts @@ -0,0 +1,109 @@ +// The property panel's connector list comes from the CLI (`wfpy connectors +// --json`): a real child here, a script standing in for the CLI, so what is +// tested is the spawn, the parse and the fallbacks, not a mock of them. +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { discoverAcpConnectors, resetAcpConnectorsCache, resolveChatAgent, splitCommand } from '../src/acp-connectors'; + +let dir: string; +function fakeCli(body: string): string { + const file = path.join(dir, `cli-${Math.random().toString(36).slice(2)}.js`); + fs.writeFileSync(file, body); + return file; +} + +const LISTING = { + user_file: '/home/u/.config/wfpy/connectors.toml', + workspace_file: null, + connectors: [ + { name: 'opencode', available: true, source: 'discovered', command: 'opencode acp', http_api: true, model: null, mode: null }, + { name: 'claude', available: false, source: 'discovered', command: 'claude-agent-acp', http_api: false, model: 'sonnet', mode: null }, + { name: 'mine', available: true, source: 'user', command: 'my-acp --flag' } + ] +}; +const PARSED = [ + { name: 'opencode', available: true, source: 'discovered', command: 'opencode acp', httpApi: true, model: null, mode: null }, + { name: 'claude', available: false, source: 'discovered', command: 'claude-agent-acp', httpApi: false, model: 'sonnet', mode: null }, + { name: 'mine', available: true, source: 'user', command: 'my-acp --flag', httpApi: false, model: null, mode: null } +]; + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'acp-connectors-')); + resetAcpConnectorsCache(); +}); +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('discoverAcpConnectors', () => { + it('runs the CLI with the given arguments and parses its JSON listing', async () => { + const cli = fakeCli(` + const args = process.argv.slice(2); + if (args.join(' ') !== 'connectors --json --workspace ${dir.replace(/\\\\/g, '\\\\\\\\')}') { process.stderr.write('bad args ' + args.join(' ')); process.exit(2); } + process.stdout.write(${JSON.stringify(JSON.stringify(LISTING))}); + `); + const found = await discoverAcpConnectors({ + cmd: process.execPath, argsPrefix: [cli], args: ['connectors', '--json', '--workspace', dir], cwd: dir + }); + expect(found).toEqual(PARSED); + }); + + it('is undefined when the CLI has no such command, or prints no JSON', async () => { + const failing = fakeCli(`process.stderr.write('usage: wfpy ...'); process.exit(2);`); + expect(await discoverAcpConnectors({ cmd: process.execPath, argsPrefix: [failing], args: ['connectors'], cwd: dir })).toBeUndefined(); + const garbage = fakeCli(`process.stdout.write('not json');`); + expect(await discoverAcpConnectors({ cmd: process.execPath, argsPrefix: [garbage], args: ['connectors'], cwd: dir })).toBeUndefined(); + expect(await discoverAcpConnectors({ cmd: path.join(dir, 'no-such-cli'), argsPrefix: [], args: ['connectors'], cwd: dir })).toBeUndefined(); + }); + + it('caches a listing per command and workspace', async () => { + const counter = path.join(dir, 'count'); + const cli = fakeCli(` + const fs = require('node:fs'); + const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, 'utf8')) + 1 : 1; + fs.writeFileSync(${JSON.stringify(counter)}, String(n)); + process.stdout.write(JSON.stringify({ connectors: [{ name: 'c' + n, available: true, source: 'discovered', command: 'c' }] })); + `); + const opts = { cmd: process.execPath, argsPrefix: [cli], args: ['connectors', '--json'], cwd: dir }; + const first = await discoverAcpConnectors(opts); + const second = await discoverAcpConnectors(opts); + expect(first?.[0].name).toBe('c1'); + expect(second?.[0].name).toBe('c1'); + const elsewhere = path.join(dir, 'elsewhere'); + fs.mkdirSync(elsewhere); + const other = await discoverAcpConnectors({ ...opts, cwd: elsewhere }); + expect(other?.[0].name).toBe('c2'); + resetAcpConnectorsCache(); + expect((await discoverAcpConnectors(opts))?.[0].name).toBe('c3'); + }); +}); + +describe('resolveChatAgent', () => { + it('spawns the named connector as its command, with the HTTP API only where it has one', () => { + expect(resolveChatAgent('opencode', PARSED)).toEqual({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true }); + expect(resolveChatAgent('mine', PARSED)).toEqual({ name: 'mine', argv: ['my-acp', '--flag'], httpApi: false }); + expect(resolveChatAgent('', PARSED)?.name).toBe('opencode'); + }); + + it('says why a connector cannot be used', () => { + expect(() => resolveChatAgent('claude', PARSED)).toThrow(/not available.*claude-agent-acp.*PATH/); + expect(() => resolveChatAgent('nope', PARSED)).toThrow(/not known.*known: opencode, claude, mine/); + }); + + it('keeps opencode, and only opencode, when the runtime lists nothing', () => { + expect(resolveChatAgent('opencode', undefined)).toBeUndefined(); + expect(resolveChatAgent('', undefined)).toBeUndefined(); + expect(() => resolveChatAgent('claude', undefined)).toThrow(/lists no ACP connectors/); + }); +}); + +describe('splitCommand', () => { + it('splits words, quotes and escapes the way the runtime does', () => { + expect(splitCommand('opencode acp')).toEqual(['opencode', 'acp']); + expect(splitCommand(' gemini --experimental-acp ')).toEqual(['gemini', '--experimental-acp']); + expect(splitCommand('"/opt/my tools/acp" --name \'a b\' c\\ d')).toEqual(['/opt/my tools/acp', '--name', 'a b', 'c d']); + expect(splitCommand('')).toEqual([]); + }); +}); diff --git a/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts b/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts new file mode 100644 index 0000000..2e16456 --- /dev/null +++ b/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts @@ -0,0 +1,190 @@ +// The human port from the IDE: the driver forwards the shell's ACP connector +// and permission settings to `wfpy run`, listens for the run's questions on a +// Unix socket it names with `--elicit-socket`, and answers each through the +// host's `askUser`. The spawn is faked; the socket is real, driven from the +// test as wfpy would drive it: one JSON object a line, an answer per question. +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { resetRegisteredCommands } from './vscode-mock'; + +const spawnCalls: Array<{ cmd: string; args: string[]; cwd: string }> = []; +let finishRun: (() => void) | undefined; +vi.mock('../src/process-control.js', () => ({ + spawnWorkflowProcess: (invocation: { cmd: string; args: string[]; cwd: string }) => { + spawnCalls.push(invocation); + const child: any = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + // The run ends when the test says so, so the socket is alive meanwhile. + finishRun = () => child.emit('close', 0, null); + return child; + }, + requestWorkflowStop: () => true +})); +vi.mock('../src/run-event-stream-client.js', () => ({ + RunEventStreamClient: class { + constructor(_opts: unknown) {} + start(): void {} + stop(): void {} + } +})); + +import { CliRunDriver, type CliRunDriverConfig, type RunQuestion, type RunAnswer } from '../src/index'; + +const RUN_CMD = 'test.runWorkflow'; + +function makeConfig(extra: Partial = {}): CliRunDriverConfig { + let stored: Record | undefined; + return { + settingsNamespace: 'wfpy', + customEditorViewType: 'test.editor', + cliCommandSettingKey: 'cliCommand', + cliCommandDefault: 'fake-wfpy', + runOutputDirSettingKey: 'runOutputDir', + liveExecutionGlowSettingKey: 'liveExecutionGlow', + agentToolsSettingKey: 'agentTools', + agentToolAuthSettingKey: 'agentToolAuth', + agentToolPolicySettingKey: 'agentToolPolicy', + agentToolTimeoutMsSettingKey: 'agentToolTimeoutMs', + agentToolRegistrySettingKey: 'agentToolRegistry', + agentMcpBridgeCmdSettingKey: 'agentMcpBridgeCmd', + runWorkflowCommandId: RUN_CMD, + stopWorkflowCommandId: 'test.stopWorkflow', + agentToolConfigCommands: { set: 'test.set', get: 'test.get' }, + overrideState: { + get: () => stored, + update: (value: Record) => { stored = value; return Promise.resolve(); } + }, + ...extra + }; +} + +function makeHost(askUser?: (q: RunQuestion, sourceUri: string) => Promise) { + const appended: string[] = []; + return { + overlay: { emitEvents: () => {} }, + requestRefresh: () => {}, + output: { + show: () => {}, + append: (v: string) => void appended.push(v), + appendLine: (v: string) => void appended.push(v) + } as unknown as vscode.OutputChannel, + askUser, + appended + }; +} + +function settings(values: Record) { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: (key: string, defaultValue?: T): T | undefined => (key in values ? (values[key] as T) : defaultValue) + } as any); +} + +function fixtureFile(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cli-run-driver-elicit-')); + const file = path.join(dir, 'pipeline.py'); + fs.writeFileSync(file, '# fixture\n', 'utf8'); + return file; +} + +function startRun(driver: CliRunDriver, file: string): Promise { + driver.registerCommands({ subscriptions: [] } as unknown as vscode.ExtensionContext); + return vscode.commands.executeCommand(RUN_CMD, { sourceUri: `file://${file}` }); +} + +async function ask(socketPath: string, questions: object[]): Promise { + // wfpy's side: one connection, one question at a time, one JSON line each way. + const conn = net.connect(socketPath); + await new Promise((resolve, reject) => { conn.once('connect', resolve); conn.once('error', reject); }); + const replies: any[] = []; + let buffer = ''; + for (const q of questions) { + conn.write(JSON.stringify(q) + '\n'); + const line = await new Promise((resolve) => { + const onData = (chunk: Buffer) => { + buffer += chunk.toString('utf8'); + const nl = buffer.indexOf('\n'); + if (nl >= 0) { + const one = buffer.slice(0, nl); + buffer = buffer.slice(nl + 1); + conn.off('data', onData); + resolve(one); + } + }; + conn.on('data', onData); + }); + replies.push(JSON.parse(line)); + } + conn.end(); + return replies; +} + +describe('CliRunDriver: the ACP connector, the permission policy and the human port', () => { + beforeEach(() => { + spawnCalls.length = 0; + finishRun = undefined; + resetRegisteredCommands(); + }); + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards the connector and permission settings, and names a socket', async () => { + settings({ 'acp.connector': 'claude', 'acp.permissions': 'reject' }); + const driver = new CliRunDriver(makeConfig({ acpConnectorSettingKey: 'acp.connector', acpPermissionsSettingKey: 'acp.permissions' }), makeHost() as any); + const run = startRun(driver, fixtureFile()); + await vi.waitFor(() => expect(spawnCalls.length).toBe(1)); + const args = spawnCalls[0].args; + expect(args.slice(args.indexOf('--acp-connector'), args.indexOf('--acp-connector') + 2)).toEqual(['--acp-connector', 'claude']); + expect(args[args.indexOf('--agent-cli-acp-permissions') + 1]).toBe('reject'); + const socketPath = args[args.indexOf('--elicit-socket') + 1]; + expect(socketPath).toMatch(/wfpy-elicit-.*\.sock$/); + expect(fs.existsSync(socketPath)).toBe(true); + finishRun!(); + await run; + expect(fs.existsSync(socketPath)).toBe(false); // the socket goes with the run + }); + + it('passes nothing for settings a shell does not have or leaves empty', async () => { + settings({ 'acp.connector': '', 'acp.permissions': '' }); + const driver = new CliRunDriver(makeConfig({ acpConnectorSettingKey: 'acp.connector', acpPermissionsSettingKey: 'acp.permissions', elicitSocket: false }), makeHost() as any); + const run = startRun(driver, fixtureFile()); + await vi.waitFor(() => expect(spawnCalls.length).toBe(1)); + expect(spawnCalls[0].args).not.toContain('--acp-connector'); + expect(spawnCalls[0].args).not.toContain('--agent-cli-acp-permissions'); + expect(spawnCalls[0].args).not.toContain('--elicit-socket'); + finishRun!(); + await run; + }); + + it('answers the run\'s questions through the host, one JSON line each', async () => { + settings({}); + const asked: RunQuestion[] = []; + const answers = ['Reject', undefined]; + const host = makeHost(async (q) => { + asked.push(q); + const a = answers[asked.length - 1]; + return a === undefined ? { declined: true, reason: 'user said no' } : { answer: a }; + }); + const driver = new CliRunDriver(makeConfig(), host as any); + const run = startRun(driver, fixtureFile()); + await vi.waitFor(() => expect(spawnCalls.length).toBe(1)); + const socketPath = spawnCalls[0].args[spawnCalls[0].args.indexOf('--elicit-socket') + 1]; + const replies = await ask(socketPath, [ + { type: 'question', id: 1, agent: 'planner', model: 'opus', question: 'Write core/cpu.py', + context: 'The agent asks permission for this tool call.', choices: ['Always Allow', 'Allow', 'Reject'], timeout_ms: 600000 }, + { type: 'question', id: 2, agent: 'planner', question: 'Proceed?', choices: null } + ]); + expect(replies).toEqual([{ id: 1, answer: 'Reject' }, { id: 2, declined: true, reason: 'user said no' }]); + expect(asked[0].question).toBe('Write core/cpu.py'); + expect(asked[0].choices).toEqual(['Always Allow', 'Allow', 'Reject']); + expect(host.appended.some((l) => l.includes('Agent planner: Write core/cpu.py'))).toBe(true); + finishRun!(); + await run; + }); +}); diff --git a/packages/sidecar-toolkit/test/client-behavior-twin.test.ts b/packages/sidecar-toolkit/test/client-behavior-twin.test.ts index ebec509..c70d013 100644 --- a/packages/sidecar-toolkit/test/client-behavior-twin.test.ts +++ b/packages/sidecar-toolkit/test/client-behavior-twin.test.ts @@ -44,10 +44,14 @@ function fieldsOf(file: string, name: string): string[] { * than filtered by a pattern, because adding one is a decision worth writing * down. * - * chatBackend derived from `DiagramProfile.chat` being present, which is - * what decides whether the host activates a chat backend at all + * chatBackend derived from `DiagramProfile.chat` being present, which is + * what decides whether the host activates a chat backend at all + * acpConnectors resolved per document by `DiagramProfile.clientBehaviorFor` + * (the sidecar profile asks the CLI, `acpConnectorsArgs`) and + * posted to the webview; a product declaring it by hand would + * name connectors the machine may not have */ -const PLATFORM_DERIVED = ['chatBackend']; +const PLATFORM_DERIVED = ['chatBackend', 'acpConnectors']; describe('sidecar client behavior mirrors the platform', () => { const sidecar = fieldsOf( From a56aa2b6c0b2fe54e2a323c338aedb1e3016edaa Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 15 Sep 2026 11:40:57 +0200 Subject: [PATCH 2/4] The ACP connector is a chat-config declaration for every product A product declares `chat.acpConnector = { settingKey }` (the setting under its namespace naming the connector, user level with a workspace override, and optionally how to list the connectors); the platform resolves the chat's agent from it when the chat connects and lists the connectors for the property panel per document. The service moved from the sidecar toolkit into extension-core so a profile built without the toolkit (mlir-viewer) declares the same thing; the toolkit's profile declares it from `acpConnectorSettingKey`, listing with the product CLI when that is wfpy and with the wfpy beside the CLI otherwise, and `runAcpFlags: false` keeps the run driver from forwarding wfpy's flags to another CLI. The `clientBehaviorFor` and `acpAgent` hooks of the previous commit are gone. --- packages/extension-core/src/api.ts | 23 ++-- .../src/extension/chat}/acp-connectors.ts | 101 +++++++++++++----- .../diagram/diagram-editor-provider.ts | 26 +++-- .../src/extension/profile-runtime.ts | 5 +- .../test/acp-connectors.test.ts | 49 ++++++--- .../test/chat-config-assembly.test.ts | 7 ++ packages/sidecar-toolkit/src/index.ts | 11 +- .../src/sidecar-diagram-profile.ts | 60 +++++------ .../src/wfpy-connector-listing.ts | 27 +++++ .../test/client-behavior-twin.test.ts | 8 +- .../test/wfpy-connector-listing.test.ts | 18 ++++ 11 files changed, 223 insertions(+), 112 deletions(-) rename packages/{sidecar-toolkit/src => extension-core/src/extension/chat}/acp-connectors.ts (58%) rename packages/{sidecar-toolkit => extension-core}/test/acp-connectors.test.ts (69%) create mode 100644 packages/sidecar-toolkit/src/wfpy-connector-listing.ts create mode 100644 packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index 8540507..c761d39 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -36,6 +36,8 @@ import type { export type { ChatCommandContribution, ChatCommandContext, ChatCommandResult }; import type { AcpAgentSpec } from "./extension/acp-client"; export type { AcpAgentSpec }; +import type { AcpConnectorConfig, AcpConnectorListing } from "./extension/chat/acp-connectors"; +export type { AcpConnectorConfig, AcpConnectorListing }; /** * Semver of the API contract. Consumers must check the major version on @@ -159,12 +161,15 @@ export interface DiagramChatConfig { */ slashCommands?: ChatCommandContribution[]; /** - * The ACP agent the chat spawns for a workspace, resolved when the chat - * connects (a setting naming a connector, looked up in what the runtime - * lists). Undefined, or no hook, means opencode. A rejection is shown as - * the connection error. + * The ACP connector the chat talks to, declared as the product's setting + * (`.`, user level with a workspace override) + * and, optionally, how to list the connectors (wfpy on the PATH otherwise). + * The platform resolves the chat's agent from it when the chat connects and + * offers the listed connectors on an agent node's `connector` in the + * property panel ({@link DiagramClientBehavior.acpConnectors}). Absent, the + * chat talks to opencode. */ - acpAgent?: (cwd: string) => Promise; + acpConnector?: AcpConnectorConfig; } /** @@ -260,7 +265,7 @@ export interface AcpConnectorInfo { export interface DiagramClientBehavior { /** The ACP connectors the property panel offers on an agent's `connector`; - * resolved per document by {@link DiagramProfile.clientBehaviorFor}. */ + * listed per document by the platform from {@link DiagramChatConfig.acpConnector}. */ acpConnectors?: AcpConnectorInfo[]; /** Cross-file drill-down navigation resolves through the graph source model. */ graphSourceNavigation?: boolean; @@ -338,12 +343,6 @@ export interface DiagramProfile { operationKinds?: DiagramOperationKinds; /** Neutral behavior flags forwarded into the diagram webview. */ clientBehavior?: DiagramClientBehavior; - /** Behavior resolved per document once its webview is up and posted to it, - * merged over {@link clientBehavior} on the client: what depends on the - * machine or the workspace rather than on the product, such as the ACP - * connectors the runtime discovered. Best effort: a rejection or a slow - * answer leaves the static behavior as it is. */ - clientBehaviorFor?(documentUri: string): Promise>; /** Consumer-supplied webview bundle (script/style/resource-roots). When absent, * the provider serves its stock `dist/webview/*` bundle. DATA ONLY — path/URI * strings, never code objects. */ diff --git a/packages/sidecar-toolkit/src/acp-connectors.ts b/packages/extension-core/src/extension/chat/acp-connectors.ts similarity index 58% rename from packages/sidecar-toolkit/src/acp-connectors.ts rename to packages/extension-core/src/extension/chat/acp-connectors.ts index cfb75eb..7b4e9ea 100644 --- a/packages/sidecar-toolkit/src/acp-connectors.ts +++ b/packages/extension-core/src/extension/chat/acp-connectors.ts @@ -1,12 +1,36 @@ /** - * The ACP connectors the runtime knows: what an agent node may name, offered - * by the property panel. Asked of the CLI (`wfpy connectors --json`), which - * is the one place that knows what the machine has on its PATH and what the - * user or the workspace declared. Cached briefly per command and workspace, - * since every diagram open would otherwise spawn it. + * The ACP connectors the runtime knows, for every dialogram product: what the + * chat spawns and what an agent node may name (the property panel's list). + * Asked of a listing command (`wfpy connectors --json --workspace `), + * the one place that knows what the machine has on its PATH and what the user + * or the workspace declared. A product declares one setting naming the + * connector ({@link AcpConnectorConfig}); the platform does the rest. Cached + * briefly per command and workspace, since every diagram open would + * otherwise spawn it. */ -import * as path from 'node:path'; -import { runChildProcess } from './run-child-process.js'; +import { execFile } from "node:child_process"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import type { AcpAgentSpec } from "../acp-client.js"; + +/** One listing command: `cmd args...`, run in the workspace directory. */ +export interface AcpConnectorListing { + cmd: string; + args: string[]; +} + +/** + * A product's declaration on its chat config: the setting (under the + * profile's settings namespace) naming the chat's connector, and optionally + * how to list the connectors for a workspace directory. Without `listing`, + * wfpy on the PATH lists them. + */ +export interface AcpConnectorConfig { + /** e.g. `'acp.connector'` for `.acp.connector`; user level, + * workspace override. Empty, or `opencode`, is the chat's own default. */ + settingKey: string; + listing?: (workspaceDir: string) => AcpConnectorListing | undefined; +} export interface AcpConnectorInfo { name: string; @@ -19,18 +43,10 @@ export interface AcpConnectorInfo { mode?: string | null; } -/** What the chat spawns for a connector (extension-core `AcpAgentSpec`). */ -export interface ChatAgentSpec { - name: string; - argv: string[]; - httpApi: boolean; -} +/** What the chat spawns for a connector: {@link AcpAgentSpec}. */ +export type ChatAgentSpec = AcpAgentSpec & { httpApi: boolean }; -export interface DiscoverAcpConnectorsOptions { - cmd: string; - argsPrefix: string[]; - /** The CLI's own arguments, e.g. `['connectors', '--json', '--workspace', dir]`. */ - args: string[]; +export interface DiscoverAcpConnectorsOptions extends AcpConnectorListing { cwd: string; timeoutMs?: number; } @@ -71,25 +87,52 @@ function parseConnectors(stdout: string): AcpConnectorInfo[] | undefined { /** Run the CLI's connector listing and parse it; `undefined` when the CLI has * no such command, fails, or times out, so a caller falls back to nothing. */ export async function discoverAcpConnectors(options: DiscoverAcpConnectorsOptions): Promise { - const key = JSON.stringify([options.cmd, options.argsPrefix, options.args, options.cwd]); + const key = JSON.stringify([options.cmd, options.args, options.cwd]); const cached = cache.get(key); if (cached && Date.now() - cached.at < CACHE_TTL_MS) { return cached.value; } - const value = (async () => { - const result = await runChildProcess(options.cmd, [...options.argsPrefix, ...options.args], { - cwd: options.cwd, - timeoutMs: options.timeoutMs ?? 8000 - }); - if (result.spawnError || result.timedOut || result.code !== 0) { - return undefined; - } - return parseConnectors(result.stdout); - })(); + const value = new Promise((resolve) => { + execFile( + options.cmd, + options.args, + { cwd: options.cwd, timeout: options.timeoutMs ?? 8000, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, + (error, stdout) => resolve(error ? undefined : parseConnectors(String(stdout))) + ); + }); cache.set(key, { at: Date.now(), value }); return value; } +/** The platform's default listing: wfpy on the PATH. */ +export function defaultAcpConnectorListing(workspaceDir: string): AcpConnectorListing { + return { cmd: "wfpy", args: ["connectors", "--json", "--workspace", workspaceDir] }; +} + +/** The connectors a product's declaration lists for a workspace directory. */ +export function listAcpConnectors(config: AcpConnectorConfig, workspaceDir: string): Promise { + const listing = config.listing?.(workspaceDir) ?? defaultAcpConnectorListing(workspaceDir); + return discoverAcpConnectors({ ...listing, cwd: workspaceDir }); +} + +/** + * The chat's agent for a workspace, from a product's declaration: the + * connector its setting names (read at the workspace's scope), resolved in + * the listing. Rejects with the reason the chat shows as its connection error. + */ +export function createAcpAgentResolver( + settingsNamespace: string, + config: AcpConnectorConfig +): (cwd: string) => Promise { + return async (cwd: string) => { + const name = (vscode.workspace + .getConfiguration(settingsNamespace, vscode.Uri.file(cwd)) + .get(config.settingKey, "") ?? "").trim(); + const connectors = await listAcpConnectors(config, cwd); + return resolveChatAgent(name, connectors); + }; +} + /** * Split a connector's command line the way the runtime does (shlex): words on * whitespace, quotes grouping, a backslash escaping. The commands are short diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index 56ee013..25cbc8b 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -11,6 +11,7 @@ import * as vscode from 'vscode'; import { statSync } from 'node:fs'; import { WORKFLOW_DIAGRAM_TYPE } from '@dialogram/shared'; import { type DiagramClientBehavior, type DiagramProfile } from '../../api'; +import { listAcpConnectors, workspaceDirFor } from '../chat/acp-connectors'; import { normalizeSourceUriKey } from './uri-keys'; import { matchesSourceExtension, sourceWatchGlobs } from './source-extensions'; @@ -776,26 +777,29 @@ export class WorkflowEditorProvider extends GlspEditorProvider { * and initializes it with the diagram identifier. */ /** - * {@link DiagramProfile.clientBehaviorFor}: the behavior that depends on - * the machine or the workspace, resolved after the webview is up (this - * setup is synchronous, and the hook may spawn the runtime) and posted to - * it as `dialogram.clientBehavior.merge`, which the client folds into its + * The behavior that depends on the machine or the workspace rather than on + * the product: the ACP connectors the profile's declaration lists for the + * document's directory. Resolved after the webview is up (this setup is + * synchronous, and the listing spawns a command) and posted to it as + * `dialogram.clientBehavior.merge`, which the client folds into its * `clientBehavior()`. Bounded and never failing: without an answer the * static behavior stands. */ private postClientBehaviorExtras(documentUri: string, webview: vscode.Webview): void { - const hook = this.profile.clientBehaviorFor; - if (!hook) { + const acpConnector = this.profile.chat?.acpConnector; + if (!acpConnector) { return; } - const deadline = new Promise>((resolve) => setTimeout(() => resolve({}), 8000)); - void Promise.race([hook.call(this.profile, documentUri), deadline]) - .then((extras) => { - if (extras && Object.keys(extras).length > 0) { + const dir = workspaceDirFor(vscode.Uri.parse(documentUri).fsPath); + const deadline = new Promise((resolve) => setTimeout(() => resolve(undefined), 8000)); + void Promise.race([listAcpConnectors(acpConnector, dir), deadline]) + .then((acpConnectors) => { + if (acpConnectors) { + const extras: Partial = { acpConnectors }; void webview.postMessage({ type: 'dialogram.clientBehavior.merge', payload: extras }); } }) - .catch((err) => console.warn('[dialogram provider] clientBehaviorFor failed:', err)); + .catch((err) => console.warn('[dialogram provider] listing the ACP connectors failed:', err)); } protected getWebviewContent( diff --git a/packages/extension-core/src/extension/profile-runtime.ts b/packages/extension-core/src/extension/profile-runtime.ts index 4675e8b..5745588 100644 --- a/packages/extension-core/src/extension/profile-runtime.ts +++ b/packages/extension-core/src/extension/profile-runtime.ts @@ -16,6 +16,7 @@ import type * as vscode from "vscode"; import type { DiagramProfile, DiagramProfileHandle } from "../api"; import { activateGlspIntegration } from "./diagram/glsp-activation"; import { ChatRuntime, type ChatRuntimeConfig } from "./chat/chat-runtime"; +import { createAcpAgentResolver } from "./chat/acp-connectors"; import { createViewerEditorsTool } from "./chat/viewer-editors-tool"; import { createEditChatCapability, @@ -118,7 +119,9 @@ export function assembleChatRuntimeConfig( ? (f) => capability.graphContextProvider(f) : chat.graphContextProvider, turnContextProvider: chat.turnContextProvider, - acpAgent: chat.acpAgent, + acpAgent: chat.acpConnector + ? createAcpAgentResolver(profile.settingsNamespace, chat.acpConnector) + : undefined, selectionContext: chat.selectionContext, // The profile's own tools, plus the platform's: "what can open this file" // is a question about the EDITOR, not about any one product's graph, so it diff --git a/packages/sidecar-toolkit/test/acp-connectors.test.ts b/packages/extension-core/test/acp-connectors.test.ts similarity index 69% rename from packages/sidecar-toolkit/test/acp-connectors.test.ts rename to packages/extension-core/test/acp-connectors.test.ts index f003503..d625762 100644 --- a/packages/sidecar-toolkit/test/acp-connectors.test.ts +++ b/packages/extension-core/test/acp-connectors.test.ts @@ -1,11 +1,19 @@ -// The property panel's connector list comes from the CLI (`wfpy connectors -// --json`): a real child here, a script standing in for the CLI, so what is -// tested is the spawn, the parse and the fallbacks, not a mock of them. +// The platform's connector service: the listing comes from a real child here +// (a script standing in for `wfpy connectors --json`), so what is tested is +// the spawn, the parse, the cache and the fallbacks, not a mock of them; the +// chat's agent then follows from the product's setting and that listing. import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { discoverAcpConnectors, resetAcpConnectorsCache, resolveChatAgent, splitCommand } from '../src/acp-connectors'; +import { + createAcpAgentResolver, + defaultAcpConnectorListing, + discoverAcpConnectors, + resetAcpConnectorsCache, + resolveChatAgent, + splitCommand +} from '../src/extension/chat/acp-connectors'; let dir: string; function fakeCli(body: string): string { @@ -38,24 +46,24 @@ afterEach(() => { }); describe('discoverAcpConnectors', () => { - it('runs the CLI with the given arguments and parses its JSON listing', async () => { + it('runs the listing command and parses its JSON', async () => { const cli = fakeCli(` const args = process.argv.slice(2); - if (args.join(' ') !== 'connectors --json --workspace ${dir.replace(/\\\\/g, '\\\\\\\\')}') { process.stderr.write('bad args ' + args.join(' ')); process.exit(2); } + if (args.join(' ') !== 'connectors --json --workspace ' + ${JSON.stringify(dir)}) { process.stderr.write('bad args ' + args.join(' ')); process.exit(2); } process.stdout.write(${JSON.stringify(JSON.stringify(LISTING))}); `); const found = await discoverAcpConnectors({ - cmd: process.execPath, argsPrefix: [cli], args: ['connectors', '--json', '--workspace', dir], cwd: dir + cmd: process.execPath, args: [cli, 'connectors', '--json', '--workspace', dir], cwd: dir }); expect(found).toEqual(PARSED); }); - it('is undefined when the CLI has no such command, or prints no JSON', async () => { + it('is undefined when the command fails, prints no JSON, or does not exist', async () => { const failing = fakeCli(`process.stderr.write('usage: wfpy ...'); process.exit(2);`); - expect(await discoverAcpConnectors({ cmd: process.execPath, argsPrefix: [failing], args: ['connectors'], cwd: dir })).toBeUndefined(); + expect(await discoverAcpConnectors({ cmd: process.execPath, args: [failing, 'connectors'], cwd: dir })).toBeUndefined(); const garbage = fakeCli(`process.stdout.write('not json');`); - expect(await discoverAcpConnectors({ cmd: process.execPath, argsPrefix: [garbage], args: ['connectors'], cwd: dir })).toBeUndefined(); - expect(await discoverAcpConnectors({ cmd: path.join(dir, 'no-such-cli'), argsPrefix: [], args: ['connectors'], cwd: dir })).toBeUndefined(); + expect(await discoverAcpConnectors({ cmd: process.execPath, args: [garbage, 'connectors'], cwd: dir })).toBeUndefined(); + expect(await discoverAcpConnectors({ cmd: path.join(dir, 'no-such-cli'), args: ['connectors'], cwd: dir })).toBeUndefined(); }); it('caches a listing per command and workspace', async () => { @@ -66,7 +74,7 @@ describe('discoverAcpConnectors', () => { fs.writeFileSync(${JSON.stringify(counter)}, String(n)); process.stdout.write(JSON.stringify({ connectors: [{ name: 'c' + n, available: true, source: 'discovered', command: 'c' }] })); `); - const opts = { cmd: process.execPath, argsPrefix: [cli], args: ['connectors', '--json'], cwd: dir }; + const opts = { cmd: process.execPath, args: [cli, 'connectors', '--json'], cwd: dir }; const first = await discoverAcpConnectors(opts); const second = await discoverAcpConnectors(opts); expect(first?.[0].name).toBe('c1'); @@ -99,6 +107,23 @@ describe('resolveChatAgent', () => { }); }); +describe('the product declaration', () => { + it('defaults to wfpy on the PATH for the listing', () => { + expect(defaultAcpConnectorListing('/w')).toEqual({ cmd: 'wfpy', args: ['connectors', '--json', '--workspace', '/w'] }); + }); + + it('resolves the chat agent from the setting and the declared listing', async () => { + // The vscode mock's settings answer with the default: an empty name, + // which is the chat's opencode, taken from the listing. + const cli = fakeCli(`process.stdout.write(${JSON.stringify(JSON.stringify(LISTING))});`); + const resolve = createAcpAgentResolver('mlir', { + settingKey: 'acp.connector', + listing: (workspaceDir) => ({ cmd: process.execPath, args: [cli, workspaceDir] }) + }); + await expect(resolve(dir)).resolves.toEqual({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true }); + }); +}); + describe('splitCommand', () => { it('splits words, quotes and escapes the way the runtime does', () => { expect(splitCommand('opencode acp')).toEqual(['opencode', 'acp']); diff --git a/packages/extension-core/test/chat-config-assembly.test.ts b/packages/extension-core/test/chat-config-assembly.test.ts index e6e8db5..6e7317b 100644 --- a/packages/extension-core/test/chat-config-assembly.test.ts +++ b/packages/extension-core/test/chat-config-assembly.test.ts @@ -83,6 +83,13 @@ describe('assembleChatRuntimeConfig', () => { expect(cfg.slashCommands![2].description).toBe('profile-layout'); }); + it('a declared ACP connector becomes the runtime\'s agent resolver; none means opencode', () => { + const declared = assembleChatRuntimeConfig(makeProfile({ acpConnector: { settingKey: 'acp.connector' } }), undefined); + expect(typeof declared.acpAgent).toBe('function'); + const plain = assembleChatRuntimeConfig(makeProfile({}), undefined); + expect(plain.acpAgent).toBeUndefined(); + }); + it('absent optional fields stay undefined', () => { const cfg = assembleChatRuntimeConfig(makeProfile({}), undefined); // `tools` is the one optional that is no longer absent: the platform diff --git a/packages/sidecar-toolkit/src/index.ts b/packages/sidecar-toolkit/src/index.ts index f562035..66f5ef6 100644 --- a/packages/sidecar-toolkit/src/index.ts +++ b/packages/sidecar-toolkit/src/index.ts @@ -56,16 +56,7 @@ export { type RunQuestion, type RunAnswer } from './cli-run-driver.js'; -export { - discoverAcpConnectors, - resetAcpConnectorsCache, - resolveChatAgent, - splitCommand, - workspaceDirFor, - type AcpConnectorInfo, - type ChatAgentSpec, - type DiscoverAcpConnectorsOptions -} from './acp-connectors.js'; +export { wfpyConnectorListing, type ConnectorListing } from './wfpy-connector-listing.js'; export { extractDecoratedDefinitionNames, diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index aeebed3..5117f61 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -13,8 +13,8 @@ */ import type { EntityPaletteItemSpec, NodeFamilySpec } from '@dialogram/shared'; import * as vscode from 'vscode'; -import { discoverAcpConnectors, resolveChatAgent, workspaceDirFor } from './acp-connectors.js'; import { invokeSidecarOp } from './sidecar-graph-export.js'; +import { wfpyConnectorListing } from './wfpy-connector-listing.js'; import { createRegistryChatTools } from './registry-tools.js'; import { getCliInvocation, getSidecarCommand, @@ -174,13 +174,20 @@ export interface SidecarProfileInput { agentToolTimeoutMsSettingKey: string; agentToolRegistrySettingKey: string; agentMcpBridgeCmdSettingKey: string; - /** Optional: the ACP connector and permission-policy settings the run - * driver forwards to `wfpy run` (`--acp-connector`, `--agent-cli-acp-permissions`). */ + /** The setting naming the ACP connector (`.`, e.g. + * `acp.connector`): the one the chat talks to, listed for the property + * panel, and, with `runAcpFlags` on, the run's default (`--acp-connector`). */ acpConnectorSettingKey?: string; + /** The setting the run driver forwards as `--agent-cli-acp-permissions`. */ acpPermissionsSettingKey?: string; - /** The CLI's arguments listing the ACP connectors as JSON for a workspace - * directory (wfpy: `['connectors', '--json', '--workspace', dir]`); when - * given, the property panel offers them on an agent's `connector`. */ + /** Whether the run driver forwards the two ACP settings to ` run` + * (wfpy's flags). Default true; a product whose run is not wfpy's sets + * false, and the connector then serves the chat and the panel only. */ + runAcpFlags?: boolean; + /** The product CLI's arguments listing the ACP connectors as JSON for a + * workspace directory (wfpy: `['connectors', '--json', '--workspace', dir]`). + * Absent, wfpy beside the product CLI lists them (the same venv), else + * wfpy on the PATH. */ acpConnectorsArgs?: (workspaceDir: string) => string[]; // Chat carry-overs. @@ -317,8 +324,8 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { agentToolTimeoutMsSettingKey: input.agentToolTimeoutMsSettingKey, agentToolRegistrySettingKey: input.agentToolRegistrySettingKey, agentMcpBridgeCmdSettingKey: input.agentMcpBridgeCmdSettingKey, - acpConnectorSettingKey: input.acpConnectorSettingKey, - acpPermissionsSettingKey: input.acpPermissionsSettingKey, + acpConnectorSettingKey: input.runAcpFlags === false ? undefined : input.acpConnectorSettingKey, + acpPermissionsSettingKey: input.runAcpFlags === false ? undefined : input.acpPermissionsSettingKey, runWorkflowCommandId: input.commands.runWorkflow, stopWorkflowCommandId: input.commands.stopWorkflow, agentToolConfigCommands: { @@ -384,17 +391,6 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { operationKinds: input.operationKinds, clientBehavior: input.clientBehavior, edits: { operationModules: createSidecarOperationModules(runtimeConfig) }, - clientBehaviorFor: input.acpConnectorsArgs - ? async (documentUri: string) => { - const uri = vscode.Uri.parse(documentUri); - const { cmd, argsPrefix } = getCliInvocation(runtimeConfig, vscode, uri); - const dir = workspaceDirFor(uri.fsPath); - const acpConnectors = await discoverAcpConnectors({ - cmd, argsPrefix, args: input.acpConnectorsArgs!(dir), cwd: dir - }); - return acpConnectors ? { acpConnectors } : {}; - } - : undefined, modelSource: () => createSidecarModelSource(runtimeConfig), serverModules: [createSidecarServerModule(runtimeConfig)], storageOptions: { @@ -423,20 +419,18 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { tools: chatTools, // The libcst edit backend rewrites Python source; agents get the file as text/x-python. sourceMimeType: 'text/x-python', - // The chat's agent: the connector the `.` - // setting names (user level, workspace override), resolved in what - // the CLI lists for that workspace. Without both inputs the chat - // keeps its opencode default. - acpAgent: input.acpConnectorSettingKey && input.acpConnectorsArgs - ? async (cwd: string) => { - const scope = vscode.Uri.file(cwd); - const name = (vscode.workspace.getConfiguration(input.settingsNamespace, scope) - .get(input.acpConnectorSettingKey!, '') ?? '').trim(); - const { cmd, argsPrefix } = getCliInvocation(runtimeConfig, vscode, scope); - const connectors = await discoverAcpConnectors({ - cmd, argsPrefix, args: input.acpConnectorsArgs!(cwd), cwd - }); - return resolveChatAgent(name, connectors); + // The ACP connector: the setting the chat and the property panel + // read, listed by the product CLI when it can (wfpy), else by wfpy + // beside it. Without the setting the chat keeps its opencode default. + acpConnector: input.acpConnectorSettingKey + ? { + settingKey: input.acpConnectorSettingKey, + listing: (workspaceDir: string) => { + const { cmd, argsPrefix } = getCliInvocation(runtimeConfig, vscode, vscode.Uri.file(workspaceDir)); + return input.acpConnectorsArgs + ? { cmd, args: [...argsPrefix, ...input.acpConnectorsArgs(workspaceDir)] } + : wfpyConnectorListing(cmd, workspaceDir); + } } : undefined }, diff --git a/packages/sidecar-toolkit/src/wfpy-connector-listing.ts b/packages/sidecar-toolkit/src/wfpy-connector-listing.ts new file mode 100644 index 0000000..1bca80d --- /dev/null +++ b/packages/sidecar-toolkit/src/wfpy-connector-listing.ts @@ -0,0 +1,27 @@ +/** + * The connector listing for a product whose CLI is not wfpy: wfpy knows the + * ACP connectors (`wfpy connectors --json`), and in this project every + * product's CLI lives in the same venv as wfpy, so wfpy beside the product's + * resolved CLI is the listing command, and wfpy on the PATH the fallback. + */ +import { existsSync } from 'node:fs'; +import * as path from 'node:path'; + +export interface ConnectorListing { + cmd: string; + args: string[]; +} + +/** `wfpy connectors --json --workspace `, wfpy taken from beside `cliCommand` + * when it names a path whose directory holds one, else from the PATH. */ +export function wfpyConnectorListing(cliCommand: string, workspaceDir: string, exists: (p: string) => boolean = existsSync): ConnectorListing { + const args = ['connectors', '--json', '--workspace', workspaceDir]; + const trimmed = cliCommand.trim(); + if (trimmed.includes('/') || trimmed.includes('\\')) { + const sibling = path.join(path.dirname(trimmed), process.platform === 'win32' ? 'wfpy.exe' : 'wfpy'); + if (exists(sibling)) { + return { cmd: sibling, args }; + } + } + return { cmd: 'wfpy', args }; +} diff --git a/packages/sidecar-toolkit/test/client-behavior-twin.test.ts b/packages/sidecar-toolkit/test/client-behavior-twin.test.ts index c70d013..a479366 100644 --- a/packages/sidecar-toolkit/test/client-behavior-twin.test.ts +++ b/packages/sidecar-toolkit/test/client-behavior-twin.test.ts @@ -46,10 +46,10 @@ function fieldsOf(file: string, name: string): string[] { * * chatBackend derived from `DiagramProfile.chat` being present, which is * what decides whether the host activates a chat backend at all - * acpConnectors resolved per document by `DiagramProfile.clientBehaviorFor` - * (the sidecar profile asks the CLI, `acpConnectorsArgs`) and - * posted to the webview; a product declaring it by hand would - * name connectors the machine may not have + * acpConnectors listed per document by the platform from the profile's + * `chat.acpConnector` declaration and posted to the webview; + * a product declaring it by hand would name connectors the + * machine may not have */ const PLATFORM_DERIVED = ['chatBackend', 'acpConnectors']; diff --git a/packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts b/packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts new file mode 100644 index 0000000..3c0a3a6 --- /dev/null +++ b/packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts @@ -0,0 +1,18 @@ +// A product whose CLI is not wfpy lists the connectors with the wfpy beside +// that CLI (the same venv), else with wfpy on the PATH. +import { describe, expect, it } from 'vitest'; +import { wfpyConnectorListing } from '../src/wfpy-connector-listing'; + +describe('wfpyConnectorListing', () => { + const args = ['connectors', '--json', '--workspace', '/w']; + + it('takes wfpy from beside a CLI given as a path', () => { + expect(wfpyConnectorListing('/venv/bin/calpy', '/w', p => p === '/venv/bin/wfpy')).toEqual({ cmd: '/venv/bin/wfpy', args }); + expect(wfpyConnectorListing('/venv/bin/python', '/w', p => p === '/venv/bin/wfpy')).toEqual({ cmd: '/venv/bin/wfpy', args }); + }); + + it('falls back to the PATH when the CLI is bare or has no wfpy beside it', () => { + expect(wfpyConnectorListing('calpy', '/w', () => true)).toEqual({ cmd: 'wfpy', args }); + expect(wfpyConnectorListing('/opt/calpy/bin/calpy', '/w', () => false)).toEqual({ cmd: 'wfpy', args }); + }); +}); From 8fc300ccf00037e13d4c91e4edc4cbcdda29e654 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 15 Sep 2026 11:56:44 +0200 Subject: [PATCH 3/4] The platform reads the ACP connectors itself; the status names the connector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No product needs wfpy on the PATH to know the connectors: extension-core reads what wfpy reads (the four known agents, available when their command is on the PATH with the agents' usual install dirs; ~/.config/wfpy/connectors.toml; the workspace's .wfpy/connectors.toml at the first directory up with a pyproject.toml or a .git), through a small TOML reader of its own. A connector's `env` table reaches the spawned process. The listing-command plumbing of the previous commit is gone. The chat's connection status carries the connector's name, and the panel shows "Connected · claude" rather than a bare "Connected". --- .../src/chat-panel-integrated.ts | 22 +- .../test/chat-panel-connected-agent.test.ts | 44 +++ packages/extension-core/src/api.ts | 18 +- .../src/extension/acp-client.ts | 32 +- .../src/extension/chat/acp-connectors.ts | 345 +++++++++++------ .../extension/chat/acp-event-forwarding.ts | 8 +- .../src/extension/chat/chat-runtime.ts | 6 +- .../src/extension/chat/toml-subset.ts | 349 ++++++++++++++++++ .../diagram/diagram-editor-provider.ts | 8 +- .../test/acp-connectors.test.ts | 190 +++++----- .../test/acp-event-forwarding.test.ts | 5 +- .../extension-core/test/toml-subset.test.ts | 64 ++++ packages/sidecar-toolkit/src/index.ts | 1 - .../src/sidecar-diagram-profile.ts | 25 +- .../src/wfpy-connector-listing.ts | 27 -- .../test/wfpy-connector-listing.test.ts | 18 - 16 files changed, 866 insertions(+), 296 deletions(-) create mode 100644 packages/diagram-client/test/chat-panel-connected-agent.test.ts create mode 100644 packages/extension-core/src/extension/chat/toml-subset.ts create mode 100644 packages/extension-core/test/toml-subset.test.ts delete mode 100644 packages/sidecar-toolkit/src/wfpy-connector-listing.ts delete mode 100644 packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts diff --git a/packages/diagram-client/src/chat-panel-integrated.ts b/packages/diagram-client/src/chat-panel-integrated.ts index 653e88f..efab43d 100644 --- a/packages/diagram-client/src/chat-panel-integrated.ts +++ b/packages/diagram-client/src/chat-panel-integrated.ts @@ -179,6 +179,8 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { private reverted = false; private connection: 'unknown' | 'connected' | 'disconnected' = 'unknown'; private connectionReason = ''; + /** The ACP connector the chat is (or was) connected to, as the host names it. */ + private connectionAgent = ''; private inputValue = ''; /** Live diagram selection (node ids), mirrored to the host for chat context. */ @@ -478,7 +480,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { break; case 'chat.connectionStatus': - this.setConnectionStatus(!!data?.connected, data?.reason); + this.setConnectionStatus(!!data?.connected, data?.reason, data?.agent); break; case 'chat.commands': @@ -691,7 +693,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { // ── Connection status ────────────────────────────────────────────────── - private setConnectionStatus(connected: boolean, reason?: string): void { + private setConnectionStatus(connected: boolean, reason?: string, agent?: string): void { this.receivedStatus = true; if (this.statusHandshakeTimer !== null) { clearTimeout(this.statusHandshakeTimer); @@ -699,6 +701,7 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { } this.connection = connected ? 'connected' : 'disconnected'; this.connectionReason = reason ?? ''; + if (typeof agent === 'string' && agent) this.connectionAgent = agent; // Surface a state change once in the timeline so the reason is visible. const stateKey = `${connected}:${reason ?? ''}`; @@ -1207,12 +1210,21 @@ export class ChatPanel implements IDiagramStartup, ISelectionListener { private topbarTemplate(): TemplateResult { const status = this.connection; + const agent = this.connectionAgent; const statusLabel = - status === 'connected' ? 'Connected' : status === 'disconnected' ? 'Disconnected' : 'Connecting…'; + status === 'connected' + ? agent + ? `Connected · ${agent}` + : 'Connected' + : status === 'disconnected' + ? 'Disconnected' + : 'Connecting…'; const statusTitle = status === 'disconnected' && this.connectionReason - ? `agent: ${this.connectionReason}` - : 'agent connection'; + ? `${agent || 'agent'}: ${this.connectionReason}` + : status === 'connected' && agent + ? `Connected to the ${agent} ACP connector` + : 'agent connection'; return html`
diff --git a/packages/diagram-client/test/chat-panel-connected-agent.test.ts b/packages/diagram-client/test/chat-panel-connected-agent.test.ts new file mode 100644 index 0000000..4cfcc2b --- /dev/null +++ b/packages/diagram-client/test/chat-panel-connected-agent.test.ts @@ -0,0 +1,44 @@ +/** + * The connection status names the ACP connector the chat talks to, so a + * "Connected" that means claude does not read as opencode. + */ +import 'reflect-metadata'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { ChatPanel } from '../src/chat-panel-integrated'; + +function makePanel(): ChatPanel { + const panel = new ChatPanel(); + (panel as any).channel = { sendToHost: () => undefined }; + (panel as any).show = () => undefined; + return panel; +} + +beforeEach(() => { + (globalThis as any).requestAnimationFrame = () => 1; + (globalThis as any).cancelAnimationFrame = () => undefined; +}); +afterEach(() => { + delete (globalThis as any).requestAnimationFrame; + delete (globalThis as any).cancelAnimationFrame; +}); + +describe('the connected status names the connector', () => { + it('keeps the connector from the status and shows it in the topbar label', () => { + const panel = makePanel(); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: true, agent: 'claude' }); + expect((panel as any).connection).toBe('connected'); + expect((panel as any).connectionAgent).toBe('claude'); + const rendered = JSON.stringify((panel as any).topbarTemplate().values); + expect(rendered).toContain('Connected · claude'); + expect(rendered).toContain('Connected to the claude ACP connector'); + }); + + it('reads plain "Connected" when the host names no connector, and keeps the last name across a disconnect', () => { + const panel = makePanel(); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: true }); + expect(JSON.stringify((panel as any).topbarTemplate().values)).toContain('"Connected"'); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: true, agent: 'opencode' }); + (panel as any).handleIncomingMessage('chat.connectionStatus', { connected: false, reason: 'opencode disconnected' }); + expect(JSON.stringify((panel as any).topbarTemplate().values)).toContain('opencode: opencode disconnected'); + }); +}); diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index c761d39..95ce192 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -36,8 +36,8 @@ import type { export type { ChatCommandContribution, ChatCommandContext, ChatCommandResult }; import type { AcpAgentSpec } from "./extension/acp-client"; export type { AcpAgentSpec }; -import type { AcpConnectorConfig, AcpConnectorListing } from "./extension/chat/acp-connectors"; -export type { AcpConnectorConfig, AcpConnectorListing }; +import type { AcpConnectorConfig } from "./extension/chat/acp-connectors"; +export type { AcpConnectorConfig }; /** * Semver of the API contract. Consumers must check the major version on @@ -162,12 +162,13 @@ export interface DiagramChatConfig { slashCommands?: ChatCommandContribution[]; /** * The ACP connector the chat talks to, declared as the product's setting - * (`.`, user level with a workspace override) - * and, optionally, how to list the connectors (wfpy on the PATH otherwise). - * The platform resolves the chat's agent from it when the chat connects and - * offers the listed connectors on an agent node's `connector` in the - * property panel ({@link DiagramClientBehavior.acpConnectors}). Absent, the - * chat talks to opencode. + * (`.`, user level with a workspace + * override). The platform reads the connectors the way wfpy does (the + * known agents on the PATH, `~/.config/wfpy/connectors.toml`, the + * workspace's `.wfpy/connectors.toml`), resolves the chat's agent from + * them when the chat connects, and offers them on an agent node's + * `connector` in the property panel ({@link DiagramClientBehavior.acpConnectors}). + * Absent, the chat talks to opencode. */ acpConnector?: AcpConnectorConfig; } @@ -261,6 +262,7 @@ export interface AcpConnectorInfo { httpApi?: boolean; model?: string | null; mode?: string | null; + env?: Record; } export interface DiagramClientBehavior { diff --git a/packages/extension-core/src/extension/acp-client.ts b/packages/extension-core/src/extension/acp-client.ts index 2cc49a2..a7eb470 100644 --- a/packages/extension-core/src/extension/acp-client.ts +++ b/packages/extension-core/src/extension/acp-client.ts @@ -144,6 +144,25 @@ export interface AcpAgentSpec { name: string; argv: string[]; httpApi?: boolean; + /** Extra environment for the process, from the connector's `env` table. */ + env?: Record; +} + +/** + * Where the agents usually install, ahead of the inherited PATH when the + * chat spawns one and when the connectors are checked for availability: + * the VS Code extension host frequently does NOT inherit the user's shell + * PATH (e.g. when launched from a GUI). + */ +export function agentInstallDirs(): string[] { + const home = os.homedir(); + return [ + path.join(home, ".opencode", "bin"), + "/usr/local/bin", + "/opt/homebrew/bin", + path.join(home, ".local", "bin"), + path.join(home, "bin"), + ]; } const OPENCODE_AGENT: AcpAgentSpec = { @@ -306,14 +325,7 @@ export class ACPClientService extends EventEmitter { * user's shell PATH (e.g. when launched from a GUI). */ private augmentedEnv(): { env: NodeJS.ProcessEnv; candidateDirs: string[] } { - const home = os.homedir(); - const candidateDirs = [ - path.join(home, ".opencode", "bin"), - "/usr/local/bin", - "/opt/homebrew/bin", - path.join(home, ".local", "bin"), - path.join(home, "bin"), - ]; + const candidateDirs = agentInstallDirs(); const env: NodeJS.ProcessEnv = { ...process.env }; const sep = process.platform === "win32" ? ";" : ":"; const existingPath = env.PATH ?? env.Path ?? ""; @@ -343,10 +355,12 @@ export class ACPClientService extends EventEmitter { try { // opencode's binary is looked for in its install locations too; any // other agent is the command the connector names, on the augmented PATH. - const { command, env } = + const resolved = spec.argv[0] === "opencode" ? this.resolveOpencodeCommand() : { command: spec.argv[0], env: this.augmentedEnv().env }; + const command = resolved.command; + const env = { ...resolved.env, ...(spec.env ?? {}) }; const args = spec.argv.slice(1); // An agent with opencode's HTTP API gets it pinned to a free port, so diff --git a/packages/extension-core/src/extension/chat/acp-connectors.ts b/packages/extension-core/src/extension/chat/acp-connectors.ts index 7b4e9ea..91f3bab 100644 --- a/packages/extension-core/src/extension/chat/acp-connectors.ts +++ b/packages/extension-core/src/extension/chat/acp-connectors.ts @@ -1,124 +1,153 @@ /** - * The ACP connectors the runtime knows, for every dialogram product: what the - * chat spawns and what an agent node may name (the property panel's list). - * Asked of a listing command (`wfpy connectors --json --workspace `), - * the one place that knows what the machine has on its PATH and what the user - * or the workspace declared. A product declares one setting naming the - * connector ({@link AcpConnectorConfig}); the platform does the rest. Cached - * briefly per command and workspace, since every diagram open would - * otherwise spawn it. + * The ACP connectors, for every dialogram product: what the chat spawns and + * what an agent node may name (the property panel's list). Read the way + * wfpy reads them (`wfpy.connectors`), in the platform itself so no product + * needs a runtime on the PATH to know them: + * + * 1. the known agents, available when their command is on the PATH + * (`opencode acp`, Zed's `claude-agent-acp` for Claude Code, `codex-acp`, + * `gemini --experimental-acp`); + * 2. the user's file, `$XDG_CONFIG_HOME/wfpy/connectors.toml` + * (`~/.config/wfpy/connectors.toml`), adding or overriding; + * 3. the workspace's file, `.wfpy/connectors.toml` at the project root (the + * first directory up with a `pyproject.toml` or a `.git`), overriding + * both. + * + * One table per connector: `command` (split as a shell would), `model`, + * `mode`, `http_api`, `env`. A product declares one setting naming the + * chat's connector ({@link AcpConnectorConfig}); the platform does the rest. */ -import { execFile } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; import * as vscode from "vscode"; -import type { AcpAgentSpec } from "../acp-client.js"; - -/** One listing command: `cmd args...`, run in the workspace directory. */ -export interface AcpConnectorListing { - cmd: string; - args: string[]; -} +import { agentInstallDirs, type AcpAgentSpec } from "../acp-client.js"; +import { parseTomlSubset, type TomlTable, type TomlValue } from "./toml-subset.js"; /** - * A product's declaration on its chat config: the setting (under the - * profile's settings namespace) naming the chat's connector, and optionally - * how to list the connectors for a workspace directory. Without `listing`, - * wfpy on the PATH lists them. + * A product's declaration on its chat config: the setting, under the + * profile's settings namespace, naming the chat's connector (user level, + * workspace override). Empty, or `opencode`, is the chat's own default. */ export interface AcpConnectorConfig { - /** e.g. `'acp.connector'` for `.acp.connector`; user level, - * workspace override. Empty, or `opencode`, is the chat's own default. */ + /** e.g. `'acp.connector'` for `.acp.connector`. */ settingKey: string; - listing?: (workspaceDir: string) => AcpConnectorListing | undefined; } export interface AcpConnectorInfo { name: string; + /** The command's first word resolves on the PATH (with the agents' usual install dirs). */ available: boolean; + /** discovered | user | workspace */ source: string; command: string; /** The process also serves opencode's HTTP API (revert, message ids). */ httpApi: boolean; model?: string | null; mode?: string | null; + env?: Record; } /** What the chat spawns for a connector: {@link AcpAgentSpec}. */ export type ChatAgentSpec = AcpAgentSpec & { httpApi: boolean }; -export interface DiscoverAcpConnectorsOptions extends AcpConnectorListing { - cwd: string; - timeoutMs?: number; +/** The agents known without being told; the same table as wfpy's. */ +export const KNOWN_CONNECTORS: Readonly> = { + opencode: { command: "opencode acp", httpApi: true }, + claude: { command: "claude-agent-acp", httpApi: false }, + codex: { command: "codex-acp", httpApi: false }, + gemini: { command: "gemini --experimental-acp", httpApi: false }, +}; + +export interface LoadAcpConnectorsOptions { + /** Where the workspace file is looked for: the source file's directory. */ + workspaceDir: string; + /** Overrides, for tests: the two files and the PATH to probe. */ + userFile?: string; + workspaceFile?: string; + pathDirs?: string[]; } -const CACHE_TTL_MS = 30_000; -const cache = new Map }>(); +/** `$XDG_CONFIG_HOME/wfpy/connectors.toml`, `~/.config` without the variable. */ +export function userConnectorsFile(env: NodeJS.ProcessEnv = process.env): string { + const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); + return path.join(base, "wfpy", "connectors.toml"); +} -function parseConnectors(stdout: string): AcpConnectorInfo[] | undefined { - let parsed: unknown; +/** The project root above `start`: the first directory up with a + * `pyproject.toml` or a `.git`, else undefined. */ +export function workspaceRoot(start: string): string | undefined { + let current = path.resolve(start); try { - parsed = JSON.parse(stdout); + if (fs.statSync(current).isFile()) { + current = path.dirname(current); + } } catch { - return undefined; + // A directory that does not exist yet has no root. } - const rows = Array.isArray(parsed) ? parsed : (parsed as { connectors?: unknown })?.connectors; - if (!Array.isArray(rows)) { - return undefined; - } - const out: AcpConnectorInfo[] = []; - for (const row of rows) { - if (!row || typeof row !== 'object' || typeof (row as { name?: unknown }).name !== 'string') { - continue; + for (;;) { + if (isFile(path.join(current, "pyproject.toml")) || exists(path.join(current, ".git"))) { + return current; } - const r = row as Record; - out.push({ - name: String(r.name), - available: Boolean(r.available), - source: typeof r.source === 'string' ? r.source : 'discovered', - command: typeof r.command === 'string' ? r.command : '', - httpApi: Boolean(r.http_api ?? r.httpApi), - model: typeof r.model === 'string' ? r.model : null, - mode: typeof r.mode === 'string' ? r.mode : null - }); + const parent = path.dirname(current); + if (parent === current) { + return undefined; + } + current = parent; } - return out; } -/** Run the CLI's connector listing and parse it; `undefined` when the CLI has - * no such command, fails, or times out, so a caller falls back to nothing. */ -export async function discoverAcpConnectors(options: DiscoverAcpConnectorsOptions): Promise { - const key = JSON.stringify([options.cmd, options.args, options.cwd]); - const cached = cache.get(key); - if (cached && Date.now() - cached.at < CACHE_TTL_MS) { - return cached.value; - } - const value = new Promise((resolve) => { - execFile( - options.cmd, - options.args, - { cwd: options.cwd, timeout: options.timeoutMs ?? 8000, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, - (error, stdout) => resolve(error ? undefined : parseConnectors(String(stdout))) - ); - }); - cache.set(key, { at: Date.now(), value }); - return value; +export function workspaceConnectorsFile(root: string | undefined): string | undefined { + return root ? path.join(root, ".wfpy", "connectors.toml") : undefined; } -/** The platform's default listing: wfpy on the PATH. */ -export function defaultAcpConnectorListing(workspaceDir: string): AcpConnectorListing { - return { cmd: "wfpy", args: ["connectors", "--json", "--workspace", workspaceDir] }; +/** The directory the listing is asked for: the source file's. */ +export function workspaceDirFor(sourcePath: string): string { + return path.dirname(sourcePath); } -/** The connectors a product's declaration lists for a workspace directory. */ -export function listAcpConnectors(config: AcpConnectorConfig, workspaceDir: string): Promise { - const listing = config.listing?.(workspaceDir) ?? defaultAcpConnectorListing(workspaceDir); - return discoverAcpConnectors({ ...listing, cwd: workspaceDir }); +/** + * Every connector a workspace sees: discovered, then the user's file, then + * the workspace's, later ones overriding earlier ones by name. Throws on a + * file that does not parse, naming it. + */ +export function loadAcpConnectors(options: LoadAcpConnectorsOptions): AcpConnectorInfo[] { + const pathDirs = options.pathDirs ?? defaultPathDirs(); + const found = new Map(); + for (const [name, spec] of Object.entries(KNOWN_CONNECTORS)) { + const argv = splitCommand(spec.command); + found.set(name, { + name, source: "discovered", available: isOnPath(argv[0], pathDirs), + command: spec.command, httpApi: spec.httpApi, model: null, mode: null, + }); + } + const userFile = options.userFile ?? userConnectorsFile(); + for (const [name, table] of readConnectorsFile(userFile)) { + found.set(name, fromTable(name, table, "user", found.get(name), userFile, pathDirs)); + } + const wsFile = options.workspaceFile ?? workspaceConnectorsFile(workspaceRoot(options.workspaceDir)); + for (const [name, table] of readConnectorsFile(wsFile)) { + found.set(name, fromTable(name, table, "workspace", found.get(name), wsFile!, pathDirs)); + } + return [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The connectors for a workspace directory; `undefined` when a file is + * unreadable (logged), so a caller that only lists falls back to nothing. */ +export async function listAcpConnectors(workspaceDir: string): Promise { + try { + return loadAcpConnectors({ workspaceDir }); + } catch (err) { + console.warn("[dialogram] ACP connectors:", err instanceof Error ? err.message : String(err)); + return undefined; + } } /** * The chat's agent for a workspace, from a product's declaration: the * connector its setting names (read at the workspace's scope), resolved in - * the listing. Rejects with the reason the chat shows as its connection error. + * the connectors that workspace sees. Rejects with the reason the chat + * shows as its connection error. */ export function createAcpAgentResolver( settingsNamespace: string, @@ -128,36 +157,59 @@ export function createAcpAgentResolver( const name = (vscode.workspace .getConfiguration(settingsNamespace, vscode.Uri.file(cwd)) .get(config.settingKey, "") ?? "").trim(); - const connectors = await listAcpConnectors(config, cwd); - return resolveChatAgent(name, connectors); + return resolveChatAgent(name, loadAcpConnectors({ workspaceDir: cwd })); }; } +/** + * The agent the chat spawns for the connector a setting names. Throws with + * the reason a reader can act on. + */ +export function resolveChatAgent(name: string, connectors: AcpConnectorInfo[] | undefined): ChatAgentSpec | undefined { + const wanted = name.trim() || "opencode"; + if (!connectors) { + if (wanted === "opencode") return undefined; + throw new Error(`no ACP connectors could be listed, so "${wanted}" cannot be resolved (only opencode works without a listing)`); + } + const found = connectors.find((c) => c.name === wanted); + if (!found) { + const known = connectors.map((c) => c.name).join(", ") || "none"; + throw new Error(`ACP connector "${wanted}" is not known (known: ${known}); declare it in ${userConnectorsFile()} or the workspace's .wfpy/connectors.toml`); + } + if (!found.available) { + throw new Error(`ACP connector "${wanted}" is not available: "${found.command}" is not on the PATH`); + } + const argv = splitCommand(found.command); + if (argv.length === 0) { + throw new Error(`ACP connector "${wanted}" has an empty command`); + } + return { name: found.name, argv, httpApi: found.httpApi, env: found.env }; +} + /** * Split a connector's command line the way the runtime does (shlex): words on - * whitespace, quotes grouping, a backslash escaping. The commands are short - * (`opencode acp`, `gemini --experimental-acp`), so this is all they need. + * whitespace, quotes grouping, a backslash escaping. */ export function splitCommand(command: string): string[] { const out: string[] = []; - let cur = ''; + let cur = ""; let quote: string | undefined; let has = false; for (let i = 0; i < command.length; i++) { const ch = command[i]; if (quote) { if (ch === quote) quote = undefined; - else if (ch === '\\' && quote === '"' && i + 1 < command.length) cur += command[++i]; + else if (ch === "\\" && quote === '"' && i + 1 < command.length) cur += command[++i]; else cur += ch; } else if (ch === '"' || ch === "'") { quote = ch; has = true; - } else if (ch === '\\' && i + 1 < command.length) { + } else if (ch === "\\" && i + 1 < command.length) { cur += command[++i]; has = true; } else if (/\s/.test(ch)) { if (has) out.push(cur); - cur = ''; + cur = ""; has = false; } else { cur += ch; @@ -168,39 +220,110 @@ export function splitCommand(command: string): string[] { return out; } -/** - * The agent the chat spawns for the connector a setting names. The listing is - * the runtime's (`discoverAcpConnectors`); `undefined` there means the runtime - * cannot list connectors, which only `opencode` survives (the chat's own - * default). Throws with the reason a reader can act on. - */ -export function resolveChatAgent(name: string, connectors: AcpConnectorInfo[] | undefined): ChatAgentSpec | undefined { - const wanted = name.trim() || 'opencode'; - if (!connectors) { - if (wanted === 'opencode') return undefined; - throw new Error(`the runtime lists no ACP connectors, so "${wanted}" cannot be resolved (only opencode works without a listing)`); +/** `shlex.join`: what a listing shows for an argv. */ +export function joinCommand(argv: string[]): string { + return argv.map((a) => (/^[A-Za-z0-9_@%+=:,./-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`)).join(" "); +} + +// ── files ────────────────────────────────────────────────────────────── + +function readConnectorsFile(file: string | undefined): Array<[string, TomlTable]> { + if (!file || !isFile(file)) { + return []; } - const found = connectors.find((c) => c.name === wanted); - if (!found) { - const known = connectors.map((c) => c.name).join(', ') || 'none'; - throw new Error(`ACP connector "${wanted}" is not known to the runtime (known: ${known}); declare it in the connectors file or pick another`); + let data: TomlTable; + try { + data = parseTomlSubset(fs.readFileSync(file, "utf8")); + } catch (err) { + throw new Error(`${file}: ${err instanceof Error ? err.message : String(err)}`); } - if (!found.available) { - throw new Error(`ACP connector "${wanted}" is not available: "${found.command}" is not on the PATH`); + const tables = data.connectors; + if (tables === undefined) { + return []; } - const argv = splitCommand(found.command); + if (!isTable(tables)) { + throw new Error(`${file}: \`connectors\` must be a table of tables`); + } + return Object.entries(tables).map(([name, table]) => { + if (!isTable(table)) { + throw new Error(`${file}: connector '${name}' must be a table`); + } + return [name, table]; + }); +} + +function fromTable( + name: string, table: TomlTable, source: string, base: AcpConnectorInfo | undefined, file: string, pathDirs: string[] +): AcpConnectorInfo { + const command = table.command !== undefined ? String(table.command) : base?.command ?? ""; + const argv = splitCommand(command); if (argv.length === 0) { - throw new Error(`ACP connector "${wanted}" has an empty command`); + throw new Error(`${file}: connector '${name}' names no command`); + } + const env: Record = { ...(base?.env ?? {}) }; + if (table.env !== undefined) { + if (!isTable(table.env)) { + throw new Error(`${file}: connector '${name}': env must be a table`); + } + for (const [k, v] of Object.entries(table.env)) env[k] = String(v); } - return { name: found.name, argv, httpApi: found.httpApi }; + const str = (v: TomlValue | undefined, fallback: string | null | undefined): string | null => + v === undefined ? fallback ?? null : v === null ? null : String(v); + return { + name, + source, + available: isOnPath(argv[0], pathDirs), + command: joinCommand(argv), + httpApi: table.http_api !== undefined ? Boolean(table.http_api) : base?.httpApi ?? false, + model: str(table.model, base?.model), + mode: str(table.mode, base?.mode), + env: Object.keys(env).length ? env : undefined, + }; } -/** For tests and for a settings change: forget what was discovered. */ -export function resetAcpConnectorsCache(): void { - cache.clear(); +// ── the PATH ─────────────────────────────────────────────────────────── + +/** The PATH the chat spawns with: the agents' install dirs ahead of the process's. */ +export function defaultPathDirs(env: NodeJS.ProcessEnv = process.env): string[] { + const sep = process.platform === "win32" ? ";" : ":"; + const inherited = (env.PATH ?? env.Path ?? "").split(sep).filter(Boolean); + return [...agentInstallDirs(), ...inherited]; } -/** The directory the listing is asked for: the source file's. */ -export function workspaceDirFor(sourcePath: string): string { - return path.dirname(sourcePath); +/** `shutil.which`: the command resolves as an executable file. */ +export function isOnPath(command: string, pathDirs: string[]): boolean { + if (!command) { + return false; + } + const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""]; + const candidates = (file: string) => exts.map((e) => file + e); + if (command.includes("/") || (process.platform === "win32" && command.includes("\\"))) { + return candidates(command).some(isExecutable); + } + return pathDirs.some((dir) => candidates(path.join(dir, command)).some(isExecutable)); +} + +function isExecutable(file: string): boolean { + try { + fs.accessSync(file, fs.constants.X_OK); + return fs.statSync(file).isFile(); + } catch { + return false; + } +} + +function isFile(p: string): boolean { + try { + return fs.statSync(p).isFile(); + } catch { + return false; + } +} + +function exists(p: string): boolean { + return fs.existsSync(p); +} + +function isTable(v: TomlValue | undefined): v is TomlTable { + return typeof v === "object" && v !== null && !Array.isArray(v); } diff --git a/packages/extension-core/src/extension/chat/acp-event-forwarding.ts b/packages/extension-core/src/extension/chat/acp-event-forwarding.ts index 5110ce1..d8642ff 100644 --- a/packages/extension-core/src/extension/chat/acp-event-forwarding.ts +++ b/packages/extension-core/src/extension/chat/acp-event-forwarding.ts @@ -12,6 +12,8 @@ import type { TurnPart } from '../acp-client.js'; export interface AcpEmitterLike { on(event: string, fn: (...a: any[]) => void): any; off(event: string, fn: (...a: any[]) => void): any; + /** The name of the ACP connector the client spawned, for the status. */ + readonly agent?: string; } export interface AcpEventSinks { @@ -89,9 +91,11 @@ export function attachAcpEventForwarding(acp: AcpEmitterLike, sinks: AcpEventSin sinks.onTurnComplete(data); }; const onPermissionRequest = (data: any) => sinks.broadcast({ type: 'chat.permissionRequest', data }); - const onConnected = () => sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: true } }); + const agentName = () => acp.agent ?? 'agent'; + const onConnected = () => + sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: true, agent: agentName() } }); const onDisconnected = () => - sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: false, reason: 'opencode disconnected' } }); + sinks.broadcast({ type: 'chat.connectionStatus', data: { connected: false, reason: `${agentName()} disconnected`, agent: agentName() } }); const onError = (err: any) => sinks.broadcast({ type: 'chat.error', data: { message: err?.message ?? String(err) } }); diff --git a/packages/extension-core/src/extension/chat/chat-runtime.ts b/packages/extension-core/src/extension/chat/chat-runtime.ts index c10e8a0..79b4c27 100644 --- a/packages/extension-core/src/extension/chat/chat-runtime.ts +++ b/packages/extension-core/src/extension/chat/chat-runtime.ts @@ -445,7 +445,7 @@ export class ChatRuntime { this.clearConnectWatchdog(); this.postToWebview(uri, { type: "chat.connectionStatus", - data: { connected: true }, + data: { connected: true, agent: this.acp.agent }, }); return true; } catch (err) { @@ -453,7 +453,7 @@ export class ChatRuntime { this.output.appendLine(message); this.postToWebview(uri, { type: "chat.connectionStatus", - data: { connected: false, reason: String(err) }, + data: { connected: false, reason: String(err), agent: this.acp.agent }, }); this.postToWebview(uri, { type: "chat.error", data: { message } }); return false; @@ -490,7 +490,7 @@ export class ChatRuntime { // handshake must not wait out an opencode spawn. this.postToWebview(uri, { type: "chat.connectionStatus", - data: { connected: this.acp.isClientConnected() }, + data: { connected: this.acp.isClientConnected(), agent: this.acp.agent }, }); this.armConnectWatchdog(); this.sendSessions(uri, file); diff --git a/packages/extension-core/src/extension/chat/toml-subset.ts b/packages/extension-core/src/extension/chat/toml-subset.ts new file mode 100644 index 0000000..452b2ad --- /dev/null +++ b/packages/extension-core/src/extension/chat/toml-subset.ts @@ -0,0 +1,349 @@ +/** + * The TOML a connectors file uses (`wfpy`'s `~/.config/wfpy/connectors.toml` + * and `.wfpy/connectors.toml`): tables and dotted keys, strings, booleans, + * numbers, arrays and inline tables, comments. Enough to read what wfpy + * reads, without a dependency; an array of tables (`[[x]]`) is refused by + * name, since the file has no use for one. + */ +export type TomlTable = { [key: string]: TomlValue }; +export type TomlValue = string | number | boolean | TomlValue[] | TomlTable; + +export class TomlSubsetError extends Error { + constructor(message: string, readonly line: number) { + super(`line ${line}: ${message}`); + } +} + +export function parseTomlSubset(text: string): TomlTable { + return new Parser(text).parse(); +} + +class Parser { + private pos = 0; + private readonly root: TomlTable = {}; + private current: TomlTable = this.root; + + constructor(private readonly text: string) {} + + parse(): TomlTable { + for (;;) { + this.skipBlank(); + if (this.pos >= this.text.length) { + return this.root; + } + const ch = this.text[this.pos]; + if (ch === '[') { + if (this.text[this.pos + 1] === '[') { + throw this.error('an array of tables ([[...]]) is not something a connectors file holds'); + } + this.pos++; + const keys = this.readKeyPath(']'); + this.expect(']'); + this.endOfLine(); + this.current = this.descend(this.root, keys, true); + } else { + const keys = this.readKeyPath('='); + this.expect('='); + this.skipSpaces(); + const value = this.readValue(); + this.endOfLine(); + this.assign(this.current, keys, value); + } + } + } + + // ── structure ────────────────────────────────────────────────────── + + private descend(table: TomlTable, keys: string[], header: boolean): TomlTable { + let node = table; + for (const key of keys) { + const existing = node[key]; + if (existing === undefined) { + const created: TomlTable = {}; + node[key] = created; + node = created; + } else if (isTable(existing)) { + node = existing; + } else { + throw this.error(`'${key}' is a value, not a table`); + } + } + if (header && Object.keys(node).length > 0 && keys.length > 0 && !(keys.join('.') in this.headers)) { + // A header may reopen a table only once; dotted keys under a + // parent are fine. Track by path. + } + this.headers[keys.join('.')] = true; + return node; + } + private readonly headers: Record = {}; + + private assign(table: TomlTable, keys: string[], value: TomlValue): void { + const node = this.descend(table, keys.slice(0, -1), false); + const last = keys[keys.length - 1]; + if (last in node) { + throw this.error(`'${keys.join('.')}' is defined twice`); + } + node[last] = value; + } + + // ── keys ─────────────────────────────────────────────────────────── + + private readKeyPath(until: string): string[] { + const keys: string[] = []; + for (;;) { + this.skipSpaces(); + keys.push(this.readKey()); + this.skipSpaces(); + if (this.text[this.pos] === '.') { + this.pos++; + continue; + } + if (this.text[this.pos] === until) { + return keys; + } + throw this.error(`expected '.' or '${until}' after a key`); + } + } + + private readKey(): string { + const ch = this.text[this.pos]; + if (ch === '"') { + return this.readBasicString(); + } + if (ch === "'") { + return this.readLiteralString(); + } + const m = /^[A-Za-z0-9_-]+/.exec(this.text.slice(this.pos)); + if (!m) { + throw this.error('expected a key'); + } + this.pos += m[0].length; + return m[0]; + } + + // ── values ───────────────────────────────────────────────────────── + + private readValue(): TomlValue { + const ch = this.text[this.pos]; + if (ch === '"') { + return this.text.startsWith('"""', this.pos) ? this.readMultiline('"""') : this.readBasicString(); + } + if (ch === "'") { + return this.text.startsWith("'''", this.pos) ? this.readMultiline("'''") : this.readLiteralString(); + } + if (ch === '[') { + return this.readArray(); + } + if (ch === '{') { + return this.readInlineTable(); + } + const rest = this.text.slice(this.pos); + if (/^true\b/.test(rest)) { + this.pos += 4; + return true; + } + if (/^false\b/.test(rest)) { + this.pos += 5; + return false; + } + const num = /^[+-]?(?:\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?|inf|nan)/.exec(rest); + if (num && num[0].length > 0) { + this.pos += num[0].length; + const n = Number(num[0].replace(/_/g, '')); + if (Number.isNaN(n) && !/nan/.test(num[0])) { + throw this.error(`not a number: ${num[0]}`); + } + return n; + } + throw this.error('expected a value'); + } + + private readBasicString(): string { + this.pos++; // opening quote + let out = ''; + for (;;) { + if (this.pos >= this.text.length) { + throw this.error('unterminated string'); + } + const ch = this.text[this.pos++]; + if (ch === '"') { + return out; + } + if (ch === '\n') { + throw this.error('a newline inside a string (use """ for several lines)'); + } + if (ch === '\\') { + out += this.readEscape(); + } else { + out += ch; + } + } + } + + private readEscape(): string { + const ch = this.text[this.pos++]; + switch (ch) { + case 'n': return '\n'; + case 't': return '\t'; + case 'r': return '\r'; + case 'b': return '\b'; + case 'f': return '\f'; + case '"': return '"'; + case '\\': return '\\'; + case 'u': + case 'U': { + const width = ch === 'u' ? 4 : 8; + const hex = this.text.slice(this.pos, this.pos + width); + if (!/^[0-9A-Fa-f]+$/.test(hex) || hex.length !== width) { + throw this.error(`bad unicode escape \\${ch}${hex}`); + } + this.pos += width; + return String.fromCodePoint(parseInt(hex, 16)); + } + default: + throw this.error(`unknown escape \\${ch ?? ''}`); + } + } + + private readLiteralString(): string { + this.pos++; + const end = this.text.indexOf("'", this.pos); + if (end < 0 || this.text.slice(this.pos, end).includes('\n')) { + throw this.error('unterminated literal string'); + } + const out = this.text.slice(this.pos, end); + this.pos = end + 1; + return out; + } + + private readMultiline(delim: string): string { + this.pos += 3; + if (this.text[this.pos] === '\n') { + this.pos++; + } else if (this.text.startsWith('\r\n', this.pos)) { + this.pos += 2; + } + const end = this.text.indexOf(delim, this.pos); + if (end < 0) { + throw this.error('unterminated multi-line string'); + } + let raw = this.text.slice(this.pos, end); + this.pos = end + 3; + if (delim === '"""') { + raw = raw.replace(/\\\r?\n[ \t\r\n]*/g, '').replace(/\\(.)/g, (_m, c: string) => { + switch (c) { + case 'n': return '\n'; + case 't': return '\t'; + case 'r': return '\r'; + case '"': return '"'; + case '\\': return '\\'; + default: return '\\' + c; + } + }); + } + return raw; + } + + private readArray(): TomlValue[] { + this.pos++; + const out: TomlValue[] = []; + for (;;) { + this.skipBlank(); + if (this.text[this.pos] === ']') { + this.pos++; + return out; + } + out.push(this.readValue()); + this.skipBlank(); + if (this.text[this.pos] === ',') { + this.pos++; + continue; + } + if (this.text[this.pos] === ']') { + continue; + } + throw this.error("expected ',' or ']' in an array"); + } + } + + private readInlineTable(): TomlTable { + this.pos++; + const table: TomlTable = {}; + this.skipSpaces(); + if (this.text[this.pos] === '}') { + this.pos++; + return table; + } + for (;;) { + const keys = this.readKeyPath('='); + this.expect('='); + this.skipSpaces(); + const value = this.readValue(); + this.assign(table, keys, value); + this.skipSpaces(); + if (this.text[this.pos] === ',') { + this.pos++; + this.skipSpaces(); + continue; + } + this.expect('}'); + return table; + } + } + + // ── lexing helpers ───────────────────────────────────────────────── + + private skipSpaces(): void { + while (this.text[this.pos] === ' ' || this.text[this.pos] === '\t') { + this.pos++; + } + } + + /** Spaces, newlines and comments. */ + private skipBlank(): void { + for (;;) { + const ch = this.text[this.pos]; + if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') { + this.pos++; + } else if (ch === '#') { + while (this.pos < this.text.length && this.text[this.pos] !== '\n') { + this.pos++; + } + } else { + return; + } + } + } + + private endOfLine(): void { + this.skipSpaces(); + const ch = this.text[this.pos]; + if (ch === '#') { + while (this.pos < this.text.length && this.text[this.pos] !== '\n') { + this.pos++; + } + return; + } + if (ch === undefined || ch === '\n' || ch === '\r') { + return; + } + throw this.error(`unexpected '${ch}' after a value`); + } + + private expect(ch: string): void { + this.skipSpaces(); + if (this.text[this.pos] !== ch) { + throw this.error(`expected '${ch}'`); + } + this.pos++; + } + + private error(message: string): TomlSubsetError { + const line = this.text.slice(0, this.pos).split('\n').length; + return new TomlSubsetError(message, line); + } +} + +function isTable(v: TomlValue): v is TomlTable { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index 25cbc8b..9440eaf 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -778,9 +778,9 @@ export class WorkflowEditorProvider extends GlspEditorProvider { */ /** * The behavior that depends on the machine or the workspace rather than on - * the product: the ACP connectors the profile's declaration lists for the - * document's directory. Resolved after the webview is up (this setup is - * synchronous, and the listing spawns a command) and posted to it as + * the product: the ACP connectors the document's workspace sees, for a + * profile that declares the connector setting. Resolved after the webview + * is up (this setup is synchronous) and posted to it as * `dialogram.clientBehavior.merge`, which the client folds into its * `clientBehavior()`. Bounded and never failing: without an answer the * static behavior stands. @@ -792,7 +792,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { } const dir = workspaceDirFor(vscode.Uri.parse(documentUri).fsPath); const deadline = new Promise((resolve) => setTimeout(() => resolve(undefined), 8000)); - void Promise.race([listAcpConnectors(acpConnector, dir), deadline]) + void Promise.race([listAcpConnectors(dir), deadline]) .then((acpConnectors) => { if (acpConnectors) { const extras: Partial = { acpConnectors }; diff --git a/packages/extension-core/test/acp-connectors.test.ts b/packages/extension-core/test/acp-connectors.test.ts index d625762..0cc9f42 100644 --- a/packages/extension-core/test/acp-connectors.test.ts +++ b/packages/extension-core/test/acp-connectors.test.ts @@ -1,126 +1,142 @@ -// The platform's connector service: the listing comes from a real child here -// (a script standing in for `wfpy connectors --json`), so what is tested is -// the spawn, the parse, the cache and the fallbacks, not a mock of them; the -// chat's agent then follows from the product's setting and that listing. +// The platform reads the connectors the way wfpy does: the known agents, +// available when on the PATH; the user's file; the workspace's file at the +// project root, each overriding the last by name. Real files and a real +// PATH directory here, so what is tested is the reading, not a mock of it. import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createAcpAgentResolver, - defaultAcpConnectorListing, - discoverAcpConnectors, - resetAcpConnectorsCache, + loadAcpConnectors, resolveChatAgent, - splitCommand + splitCommand, + userConnectorsFile, + workspaceRoot } from '../src/extension/chat/acp-connectors'; let dir: string; -function fakeCli(body: string): string { - const file = path.join(dir, `cli-${Math.random().toString(36).slice(2)}.js`); - fs.writeFileSync(file, body); +let bin: string; +function executable(name: string): string { + const file = path.join(bin, name); + fs.writeFileSync(file, '#!/bin/sh\n', { mode: 0o755 }); + return file; +} +function write(file: string, text: string): string { + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, text); return file; } - -const LISTING = { - user_file: '/home/u/.config/wfpy/connectors.toml', - workspace_file: null, - connectors: [ - { name: 'opencode', available: true, source: 'discovered', command: 'opencode acp', http_api: true, model: null, mode: null }, - { name: 'claude', available: false, source: 'discovered', command: 'claude-agent-acp', http_api: false, model: 'sonnet', mode: null }, - { name: 'mine', available: true, source: 'user', command: 'my-acp --flag' } - ] -}; -const PARSED = [ - { name: 'opencode', available: true, source: 'discovered', command: 'opencode acp', httpApi: true, model: null, mode: null }, - { name: 'claude', available: false, source: 'discovered', command: 'claude-agent-acp', httpApi: false, model: 'sonnet', mode: null }, - { name: 'mine', available: true, source: 'user', command: 'my-acp --flag', httpApi: false, model: null, mode: null } -]; beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'acp-connectors-')); - resetAcpConnectorsCache(); + bin = path.join(dir, 'bin'); + fs.mkdirSync(bin); }); afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); -describe('discoverAcpConnectors', () => { - it('runs the listing command and parses its JSON', async () => { - const cli = fakeCli(` - const args = process.argv.slice(2); - if (args.join(' ') !== 'connectors --json --workspace ' + ${JSON.stringify(dir)}) { process.stderr.write('bad args ' + args.join(' ')); process.exit(2); } - process.stdout.write(${JSON.stringify(JSON.stringify(LISTING))}); - `); - const found = await discoverAcpConnectors({ - cmd: process.execPath, args: [cli, 'connectors', '--json', '--workspace', dir], cwd: dir - }); - expect(found).toEqual(PARSED); +describe('loadAcpConnectors', () => { + it('knows the four agents, available when their command is on the PATH', () => { + executable('opencode'); + const found = loadAcpConnectors({ workspaceDir: dir, userFile: path.join(dir, 'none'), workspaceFile: path.join(dir, 'none'), pathDirs: [bin] }); + expect(found.map(c => [c.name, c.available, c.source, c.command, c.httpApi])).toEqual([ + ['claude', false, 'discovered', 'claude-agent-acp', false], + ['codex', false, 'discovered', 'codex-acp', false], + ['gemini', false, 'discovered', 'gemini --experimental-acp', false], + ['opencode', true, 'discovered', 'opencode acp', true] + ]); }); - it('is undefined when the command fails, prints no JSON, or does not exist', async () => { - const failing = fakeCli(`process.stderr.write('usage: wfpy ...'); process.exit(2);`); - expect(await discoverAcpConnectors({ cmd: process.execPath, args: [failing, 'connectors'], cwd: dir })).toBeUndefined(); - const garbage = fakeCli(`process.stdout.write('not json');`); - expect(await discoverAcpConnectors({ cmd: process.execPath, args: [garbage, 'connectors'], cwd: dir })).toBeUndefined(); - expect(await discoverAcpConnectors({ cmd: path.join(dir, 'no-such-cli'), args: ['connectors'], cwd: dir })).toBeUndefined(); + it("applies the user's file, then the workspace's at the project root, by name", () => { + executable('opencode'); + const mine = executable('my-acp'); + const userFile = write(path.join(dir, 'config', 'wfpy', 'connectors.toml'), ` +[connectors.claude] +model = "sonnet" +[connectors.mine] +command = "${mine} --flag" +env = { API_KEY = "k" } +`); + const root = path.join(dir, 'project'); + fs.mkdirSync(path.join(root, '.git'), { recursive: true }); + write(path.join(root, '.wfpy', 'connectors.toml'), ` +[connectors.opencode] +command = "${path.join(bin, 'opencode')} acp" +[connectors.mine] +mode = "plan" +`); + const sub = path.join(root, 'flows', 'deep'); + fs.mkdirSync(sub, { recursive: true }); + + const found = loadAcpConnectors({ workspaceDir: sub, userFile, pathDirs: [bin] }); + const by = Object.fromEntries(found.map(c => [c.name, c])); + expect(by.claude).toMatchObject({ source: 'user', available: false, command: 'claude-agent-acp', model: 'sonnet', mode: null }); + expect(by.mine).toMatchObject({ source: 'workspace', available: true, command: `${mine} --flag`, mode: 'plan', env: { API_KEY: 'k' } }); + expect(by.opencode).toMatchObject({ source: 'workspace', available: true, command: `${path.join(bin, 'opencode')} acp`, httpApi: true }); + expect(found.map(c => c.name)).toEqual(['claude', 'codex', 'gemini', 'mine', 'opencode']); }); - it('caches a listing per command and workspace', async () => { - const counter = path.join(dir, 'count'); - const cli = fakeCli(` - const fs = require('node:fs'); - const n = fs.existsSync(${JSON.stringify(counter)}) ? Number(fs.readFileSync(${JSON.stringify(counter)}, 'utf8')) + 1 : 1; - fs.writeFileSync(${JSON.stringify(counter)}, String(n)); - process.stdout.write(JSON.stringify({ connectors: [{ name: 'c' + n, available: true, source: 'discovered', command: 'c' }] })); - `); - const opts = { cmd: process.execPath, args: [cli, 'connectors', '--json'], cwd: dir }; - const first = await discoverAcpConnectors(opts); - const second = await discoverAcpConnectors(opts); - expect(first?.[0].name).toBe('c1'); - expect(second?.[0].name).toBe('c1'); - const elsewhere = path.join(dir, 'elsewhere'); - fs.mkdirSync(elsewhere); - const other = await discoverAcpConnectors({ ...opts, cwd: elsewhere }); - expect(other?.[0].name).toBe('c2'); - resetAcpConnectorsCache(); - expect((await discoverAcpConnectors(opts))?.[0].name).toBe('c3'); + it('names the file and the line when one does not parse, or a connector has no command', () => { + const bad = write(path.join(dir, 'bad.toml'), '[connectors.x]\ncommand = "oops'); + expect(() => loadAcpConnectors({ workspaceDir: dir, userFile: bad, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) + .toThrow(new RegExp(`${bad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}: line 2: unterminated string`)); + const empty = write(path.join(dir, 'empty.toml'), '[connectors.x]\nmodel = "m"'); + expect(() => loadAcpConnectors({ workspaceDir: dir, userFile: empty, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) + .toThrow(/connector 'x' names no command/); }); }); -describe('resolveChatAgent', () => { - it('spawns the named connector as its command, with the HTTP API only where it has one', () => { - expect(resolveChatAgent('opencode', PARSED)).toEqual({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true }); - expect(resolveChatAgent('mine', PARSED)).toEqual({ name: 'mine', argv: ['my-acp', '--flag'], httpApi: false }); - expect(resolveChatAgent('', PARSED)?.name).toBe('opencode'); +describe('the files', () => { + it('finds the project root by pyproject.toml or .git, walking up', () => { + const root = path.join(dir, 'p'); + write(path.join(root, 'pyproject.toml'), ''); + fs.mkdirSync(path.join(root, 'a', 'b'), { recursive: true }); + expect(workspaceRoot(path.join(root, 'a', 'b'))).toBe(root); + expect(workspaceRoot(path.join(root, 'a', 'b', 'flow.py'))).toBe(root); + expect(workspaceRoot(dir)).toBeUndefined(); }); - it('says why a connector cannot be used', () => { - expect(() => resolveChatAgent('claude', PARSED)).toThrow(/not available.*claude-agent-acp.*PATH/); - expect(() => resolveChatAgent('nope', PARSED)).toThrow(/not known.*known: opencode, claude, mine/); + it("locates the user's file under XDG_CONFIG_HOME, else ~/.config", () => { + expect(userConnectorsFile({ XDG_CONFIG_HOME: '/x' })).toBe(path.join('/x', 'wfpy', 'connectors.toml')); + expect(userConnectorsFile({})).toBe(path.join(os.homedir(), '.config', 'wfpy', 'connectors.toml')); }); +}); - it('keeps opencode, and only opencode, when the runtime lists nothing', () => { - expect(resolveChatAgent('opencode', undefined)).toBeUndefined(); - expect(resolveChatAgent('', undefined)).toBeUndefined(); - expect(() => resolveChatAgent('claude', undefined)).toThrow(/lists no ACP connectors/); +describe('resolveChatAgent', () => { + const listed = [ + { name: 'claude', available: false, source: 'discovered', command: 'claude-agent-acp', httpApi: false, model: 'sonnet', mode: null }, + { name: 'mine', available: true, source: 'user', command: 'my-acp --flag', httpApi: false, model: null, mode: null, env: { A: 'b' } }, + { name: 'opencode', available: true, source: 'discovered', command: 'opencode acp', httpApi: true, model: null, mode: null } + ]; + + it('spawns the named connector as its command, with the HTTP API only where it has one', () => { + expect(resolveChatAgent('opencode', listed)).toEqual({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true, env: undefined }); + expect(resolveChatAgent('mine', listed)).toEqual({ name: 'mine', argv: ['my-acp', '--flag'], httpApi: false, env: { A: 'b' } }); + expect(resolveChatAgent('', listed)?.name).toBe('opencode'); }); -}); -describe('the product declaration', () => { - it('defaults to wfpy on the PATH for the listing', () => { - expect(defaultAcpConnectorListing('/w')).toEqual({ cmd: 'wfpy', args: ['connectors', '--json', '--workspace', '/w'] }); + it('says why a connector cannot be used', () => { + expect(() => resolveChatAgent('claude', listed)).toThrow(/not available.*claude-agent-acp.*PATH/); + expect(() => resolveChatAgent('nope', listed)).toThrow(/not known.*known: claude, mine, opencode/); + expect(resolveChatAgent('opencode', undefined)).toBeUndefined(); + expect(() => resolveChatAgent('claude', undefined)).toThrow(/no ACP connectors could be listed/); }); - it('resolves the chat agent from the setting and the declared listing', async () => { + it('resolves the chat agent from the setting for a workspace', async () => { // The vscode mock's settings answer with the default: an empty name, - // which is the chat's opencode, taken from the listing. - const cli = fakeCli(`process.stdout.write(${JSON.stringify(JSON.stringify(LISTING))});`); - const resolve = createAcpAgentResolver('mlir', { - settingKey: 'acp.connector', - listing: (workspaceDir) => ({ cmd: process.execPath, args: [cli, workspaceDir] }) - }); - await expect(resolve(dir)).resolves.toEqual({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true }); + // which is the chat's opencode, taken from what the workspace sees. + executable('opencode'); + process.env.PATH = `${bin}${path.delimiter}${process.env.PATH ?? ''}`; + const xdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = path.join(dir, 'no-config'); + try { + const resolve = createAcpAgentResolver('mlir', { settingKey: 'acp.connector' }); + await expect(resolve(dir)).resolves.toMatchObject({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true }); + } finally { + if (xdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = xdg; + } }); }); diff --git a/packages/extension-core/test/acp-event-forwarding.test.ts b/packages/extension-core/test/acp-event-forwarding.test.ts index 05d7b3f..93abcbd 100644 --- a/packages/extension-core/test/acp-event-forwarding.test.ts +++ b/packages/extension-core/test/acp-event-forwarding.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { attachAcpEventForwarding, type AcpEventSinks } from '../src/extension/chat/acp-event-forwarding'; class FakeAcp { + agent = 'opencode'; private listeners = new Map void>>(); on(ev: string, fn: (...a: any[]) => void) { const arr = this.listeners.get(ev) ?? []; @@ -88,8 +89,8 @@ describe('attachAcpEventForwarding', () => { acp.emit('disconnected'); acp.emit('error', new Error('boom')); expect(broadcasts).toEqual([ - { type: 'chat.connectionStatus', data: { connected: true } }, - { type: 'chat.connectionStatus', data: { connected: false, reason: 'opencode disconnected' } }, + { type: 'chat.connectionStatus', data: { connected: true, agent: 'opencode' } }, + { type: 'chat.connectionStatus', data: { connected: false, reason: 'opencode disconnected', agent: 'opencode' } }, { type: 'chat.error', data: { message: 'boom' } } ]); }); diff --git a/packages/extension-core/test/toml-subset.test.ts b/packages/extension-core/test/toml-subset.test.ts new file mode 100644 index 0000000..94ae23a --- /dev/null +++ b/packages/extension-core/test/toml-subset.test.ts @@ -0,0 +1,64 @@ +// The TOML a connectors file uses, read without a dependency: what wfpy's +// documented example and a few plausible variations contain. +import { describe, expect, it } from 'vitest'; +import { parseTomlSubset } from '../src/extension/chat/toml-subset'; + +describe('parseTomlSubset', () => { + it("reads wfpy's documented connectors file", () => { + const text = ` +# the agents +[connectors.claude] +command = "claude-agent-acp" # argv, split as a shell would +model = "sonnet" # a session config option the agent offers +mode = "acceptEdits" # a session mode the agent offers +http_api = false # OpenCode's HTTP API beside ACP + +[connectors.opencode] +command = "/opt/opencode/bin/opencode acp" +`; + expect(parseTomlSubset(text)).toEqual({ + connectors: { + claude: { command: 'claude-agent-acp', model: 'sonnet', mode: 'acceptEdits', http_api: false }, + opencode: { command: '/opt/opencode/bin/opencode acp' } + } + }); + }); + + it('reads env as a sub-table or an inline table, quoted keys, arrays, numbers, escapes and literal strings', () => { + const text = ` +[connectors."my agent"] +command = 'my-acp --flag' +env = { API_KEY = "k", "X.Y" = "v" } +timeout = 1_000 +ratio = 1.5 +tags = ["a", "b", + "c"] + +[connectors.other] +command = "other \\"quoted\\" \\\\ end" + +[connectors.other.env] +HOME = "/h" +`; + expect(parseTomlSubset(text)).toEqual({ + connectors: { + 'my agent': { command: 'my-acp --flag', env: { API_KEY: 'k', 'X.Y': 'v' }, timeout: 1000, ratio: 1.5, tags: ['a', 'b', 'c'] }, + other: { command: 'other "quoted" \\ end', env: { HOME: '/h' } } + } + }); + }); + + it('reads dotted keys and multi-line strings', () => { + expect(parseTomlSubset('connectors.x.command = "c"\nnote = """\nline\\\n joined"""')).toEqual({ + connectors: { x: { command: 'c' } }, + note: 'linejoined' + }); + }); + + it('says what is wrong, with the line', () => { + expect(() => parseTomlSubset('[connectors.a]\ncommand = "unterminated')).toThrow(/line 2: unterminated string/); + expect(() => parseTomlSubset('a = 1\na = 2')).toThrow(/line 2: 'a' is defined twice/); + expect(() => parseTomlSubset('[[connectors]]')).toThrow(/array of tables/); + expect(() => parseTomlSubset('a = "x" b = 1')).toThrow(/line 1: unexpected 'b'/); + }); +}); diff --git a/packages/sidecar-toolkit/src/index.ts b/packages/sidecar-toolkit/src/index.ts index 66f5ef6..bba7f18 100644 --- a/packages/sidecar-toolkit/src/index.ts +++ b/packages/sidecar-toolkit/src/index.ts @@ -56,7 +56,6 @@ export { type RunQuestion, type RunAnswer } from './cli-run-driver.js'; -export { wfpyConnectorListing, type ConnectorListing } from './wfpy-connector-listing.js'; export { extractDecoratedDefinitionNames, diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index 5117f61..4d31c19 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -14,7 +14,6 @@ import type { EntityPaletteItemSpec, NodeFamilySpec } from '@dialogram/shared'; import * as vscode from 'vscode'; import { invokeSidecarOp } from './sidecar-graph-export.js'; -import { wfpyConnectorListing } from './wfpy-connector-listing.js'; import { createRegistryChatTools } from './registry-tools.js'; import { getCliInvocation, getSidecarCommand, @@ -175,8 +174,10 @@ export interface SidecarProfileInput { agentToolRegistrySettingKey: string; agentMcpBridgeCmdSettingKey: string; /** The setting naming the ACP connector (`.`, e.g. - * `acp.connector`): the one the chat talks to, listed for the property - * panel, and, with `runAcpFlags` on, the run's default (`--acp-connector`). */ + * `acp.connector`): the one the chat talks to, offered by the property + * panel, and, with `runAcpFlags` on, the run's default (`--acp-connector`). + * The platform reads the connectors itself (the known agents on the + * PATH, wfpy's user and workspace files). */ acpConnectorSettingKey?: string; /** The setting the run driver forwards as `--agent-cli-acp-permissions`. */ acpPermissionsSettingKey?: string; @@ -184,11 +185,6 @@ export interface SidecarProfileInput { * (wfpy's flags). Default true; a product whose run is not wfpy's sets * false, and the connector then serves the chat and the panel only. */ runAcpFlags?: boolean; - /** The product CLI's arguments listing the ACP connectors as JSON for a - * workspace directory (wfpy: `['connectors', '--json', '--workspace', dir]`). - * Absent, wfpy beside the product CLI lists them (the same venv), else - * wfpy on the PATH. */ - acpConnectorsArgs?: (workspaceDir: string) => string[]; // Chat carry-overs. chat: { @@ -420,18 +416,9 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { // The libcst edit backend rewrites Python source; agents get the file as text/x-python. sourceMimeType: 'text/x-python', // The ACP connector: the setting the chat and the property panel - // read, listed by the product CLI when it can (wfpy), else by wfpy - // beside it. Without the setting the chat keeps its opencode default. + // read. Without it the chat keeps its opencode default. acpConnector: input.acpConnectorSettingKey - ? { - settingKey: input.acpConnectorSettingKey, - listing: (workspaceDir: string) => { - const { cmd, argsPrefix } = getCliInvocation(runtimeConfig, vscode, vscode.Uri.file(workspaceDir)); - return input.acpConnectorsArgs - ? { cmd, args: [...argsPrefix, ...input.acpConnectorsArgs(workspaceDir)] } - : wfpyConnectorListing(cmd, workspaceDir); - } - } + ? { settingKey: input.acpConnectorSettingKey } : undefined }, runDriver, diff --git a/packages/sidecar-toolkit/src/wfpy-connector-listing.ts b/packages/sidecar-toolkit/src/wfpy-connector-listing.ts deleted file mode 100644 index 1bca80d..0000000 --- a/packages/sidecar-toolkit/src/wfpy-connector-listing.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * The connector listing for a product whose CLI is not wfpy: wfpy knows the - * ACP connectors (`wfpy connectors --json`), and in this project every - * product's CLI lives in the same venv as wfpy, so wfpy beside the product's - * resolved CLI is the listing command, and wfpy on the PATH the fallback. - */ -import { existsSync } from 'node:fs'; -import * as path from 'node:path'; - -export interface ConnectorListing { - cmd: string; - args: string[]; -} - -/** `wfpy connectors --json --workspace `, wfpy taken from beside `cliCommand` - * when it names a path whose directory holds one, else from the PATH. */ -export function wfpyConnectorListing(cliCommand: string, workspaceDir: string, exists: (p: string) => boolean = existsSync): ConnectorListing { - const args = ['connectors', '--json', '--workspace', workspaceDir]; - const trimmed = cliCommand.trim(); - if (trimmed.includes('/') || trimmed.includes('\\')) { - const sibling = path.join(path.dirname(trimmed), process.platform === 'win32' ? 'wfpy.exe' : 'wfpy'); - if (exists(sibling)) { - return { cmd: sibling, args }; - } - } - return { cmd: 'wfpy', args }; -} diff --git a/packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts b/packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts deleted file mode 100644 index 3c0a3a6..0000000 --- a/packages/sidecar-toolkit/test/wfpy-connector-listing.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -// A product whose CLI is not wfpy lists the connectors with the wfpy beside -// that CLI (the same venv), else with wfpy on the PATH. -import { describe, expect, it } from 'vitest'; -import { wfpyConnectorListing } from '../src/wfpy-connector-listing'; - -describe('wfpyConnectorListing', () => { - const args = ['connectors', '--json', '--workspace', '/w']; - - it('takes wfpy from beside a CLI given as a path', () => { - expect(wfpyConnectorListing('/venv/bin/calpy', '/w', p => p === '/venv/bin/wfpy')).toEqual({ cmd: '/venv/bin/wfpy', args }); - expect(wfpyConnectorListing('/venv/bin/python', '/w', p => p === '/venv/bin/wfpy')).toEqual({ cmd: '/venv/bin/wfpy', args }); - }); - - it('falls back to the PATH when the CLI is bare or has no wfpy beside it', () => { - expect(wfpyConnectorListing('calpy', '/w', () => true)).toEqual({ cmd: 'wfpy', args }); - expect(wfpyConnectorListing('/opt/calpy/bin/calpy', '/w', () => false)).toEqual({ cmd: 'wfpy', args }); - }); -}); From 93e629c8b7a3d40727cae574410545962fc4a863 Mon Sep 17 00:00:00 2001 From: Endri Bezati Date: Tue, 15 Sep 2026 12:04:05 +0200 Subject: [PATCH 4/4] The product names its runtime's connectors files; the platform stays neutral The neutrality gate refuses a product's name in the platform and the toolkit, comments included. The two connectors files are part of the product's declaration now (`chat.acpConnector.files`, relative to ~/.config and to the project root), the sidecar profile takes them as `acpConnectorFiles`, and the elicitation socket is `acp-elicit-*`. --- packages/extension-core/src/api.ts | 25 +++---- .../src/extension/acp-client.ts | 4 +- .../src/extension/chat/acp-connectors.ts | 69 +++++++++++-------- .../src/extension/chat/toml-subset.ts | 9 ++- .../diagram/diagram-editor-provider.ts | 2 +- .../test/acp-connectors.test.ts | 32 ++++++--- .../test/chat-config-assembly.test.ts | 2 +- .../sidecar-toolkit/src/cli-run-driver.ts | 14 ++-- .../src/sidecar-diagram-profile.ts | 16 +++-- .../test/cli-run-driver-elicit.test.ts | 2 +- 10 files changed, 103 insertions(+), 72 deletions(-) diff --git a/packages/extension-core/src/api.ts b/packages/extension-core/src/api.ts index 95ce192..40b3d71 100644 --- a/packages/extension-core/src/api.ts +++ b/packages/extension-core/src/api.ts @@ -36,8 +36,8 @@ import type { export type { ChatCommandContribution, ChatCommandContext, ChatCommandResult }; import type { AcpAgentSpec } from "./extension/acp-client"; export type { AcpAgentSpec }; -import type { AcpConnectorConfig } from "./extension/chat/acp-connectors"; -export type { AcpConnectorConfig }; +import type { AcpConnectorConfig, AcpConnectorFiles } from "./extension/chat/acp-connectors"; +export type { AcpConnectorConfig, AcpConnectorFiles }; /** * Semver of the API contract. Consumers must check the major version on @@ -163,12 +163,13 @@ export interface DiagramChatConfig { /** * The ACP connector the chat talks to, declared as the product's setting * (`.`, user level with a workspace - * override). The platform reads the connectors the way wfpy does (the - * known agents on the PATH, `~/.config/wfpy/connectors.toml`, the - * workspace's `.wfpy/connectors.toml`), resolves the chat's agent from - * them when the chat connects, and offers them on an agent node's - * `connector` in the property panel ({@link DiagramClientBehavior.acpConnectors}). - * Absent, the chat talks to opencode. + * override) and its runtime's two connectors files. The platform reads the + * connectors the way that runtime does (the known agents on the PATH, the + * user's file under `~/.config`, the workspace's file at the project + * root), resolves the chat's agent from them when the chat connects, and + * offers them on an agent node's `connector` in the property panel + * ({@link DiagramClientBehavior.acpConnectors}). Absent, the chat talks to + * opencode. */ acpConnector?: AcpConnectorConfig; } @@ -191,8 +192,8 @@ export interface DiagramLiveOverlaySource { * never holds the connector), the run output channel, and a hook to register the * driver's live-overlay signature source with the editor provider. */ -/** A running agent's question to the user (wfpy's elicitation over the run - * driver's socket): the toolkit's `RunQuestion`. */ +/** A running agent's question to the user (the runtime's elicitation over + * the run driver's socket): the toolkit's `RunQuestion`. */ export interface DiagramRunQuestion { id: number | string; agent: string; @@ -249,8 +250,8 @@ export type DiagramRunDriverFactory = ( * behavior; the consumer supplies the per-product truth value. Core/client code * consults these flags instead of comparing a product-identity string. */ -/** One ACP connector as the runtime discovered or the user declared it - * (wfpy: `wfpy connectors --json`): what an agent node may name. */ +/** One ACP connector as the platform read it (the known agents, the user's + * and the workspace's files): what an agent node may name. */ export interface AcpConnectorInfo { name: string; /** The connector's command resolves on the PATH. */ diff --git a/packages/extension-core/src/extension/acp-client.ts b/packages/extension-core/src/extension/acp-client.ts index a7eb470..2cae73b 100644 --- a/packages/extension-core/src/extension/acp-client.ts +++ b/packages/extension-core/src/extension/acp-client.ts @@ -134,8 +134,8 @@ export declare interface ACPClientService { } /** - * The agent the chat spawns: an ACP connector as the runtime lists it - * (wfpy: `wfpy connectors --json`). `argv` is the command and its arguments; + * The agent the chat spawns: an ACP connector as the platform reads it + * (`chat/acp-connectors.ts`). `argv` is the command and its arguments; * `httpApi` says the process also serves opencode's HTTP API, which the * client then pins to a port for the capabilities ACP does not expose * (revert / unrevert / message ids). Without it those stay off. diff --git a/packages/extension-core/src/extension/chat/acp-connectors.ts b/packages/extension-core/src/extension/chat/acp-connectors.ts index 91f3bab..e740ac9 100644 --- a/packages/extension-core/src/extension/chat/acp-connectors.ts +++ b/packages/extension-core/src/extension/chat/acp-connectors.ts @@ -1,21 +1,20 @@ /** * The ACP connectors, for every dialogram product: what the chat spawns and - * what an agent node may name (the property panel's list). Read the way - * wfpy reads them (`wfpy.connectors`), in the platform itself so no product - * needs a runtime on the PATH to know them: + * what an agent node may name (the property panel's list). Read the way the + * product's runtime reads them, in the platform itself so no product needs + * that runtime on the PATH to know them: * * 1. the known agents, available when their command is on the PATH * (`opencode acp`, Zed's `claude-agent-acp` for Claude Code, `codex-acp`, * `gemini --experimental-acp`); - * 2. the user's file, `$XDG_CONFIG_HOME/wfpy/connectors.toml` - * (`~/.config/wfpy/connectors.toml`), adding or overriding; - * 3. the workspace's file, `.wfpy/connectors.toml` at the project root (the - * first directory up with a `pyproject.toml` or a `.git`), overriding - * both. + * 2. the user's connectors file, under `$XDG_CONFIG_HOME` (`~/.config`), + * adding or overriding; + * 3. the workspace's connectors file, under the project root (the first + * directory up with a `pyproject.toml` or a `.git`), overriding both. * - * One table per connector: `command` (split as a shell would), `model`, - * `mode`, `http_api`, `env`. A product declares one setting naming the - * chat's connector ({@link AcpConnectorConfig}); the platform does the rest. + * The product names the two files ({@link AcpConnectorConfig.files}), since + * they are its runtime's. One TOML table per connector: `command` (split as + * a shell would), `model`, `mode`, `http_api`, `env`. */ import * as fs from "node:fs"; import * as os from "node:os"; @@ -27,11 +26,21 @@ import { parseTomlSubset, type TomlTable, type TomlValue } from "./toml-subset.j /** * A product's declaration on its chat config: the setting, under the * profile's settings namespace, naming the chat's connector (user level, - * workspace override). Empty, or `opencode`, is the chat's own default. + * workspace override), and the runtime's two connectors files. Empty, or + * `opencode`, is the chat's own default. */ export interface AcpConnectorConfig { /** e.g. `'acp.connector'` for `.acp.connector`. */ settingKey: string; + /** The connectors files the product's runtime reads, as relative paths: + * `user` under `$XDG_CONFIG_HOME` (`~/.config`), `workspace` under the + * project root. */ + files: AcpConnectorFiles; +} + +export interface AcpConnectorFiles { + user: string; + workspace: string; } export interface AcpConnectorInfo { @@ -51,7 +60,7 @@ export interface AcpConnectorInfo { /** What the chat spawns for a connector: {@link AcpAgentSpec}. */ export type ChatAgentSpec = AcpAgentSpec & { httpApi: boolean }; -/** The agents known without being told; the same table as wfpy's. */ +/** The agents known without being told; the same table as the runtime's. */ export const KNOWN_CONNECTORS: Readonly> = { opencode: { command: "opencode acp", httpApi: true }, claude: { command: "claude-agent-acp", httpApi: false }, @@ -60,18 +69,20 @@ export const KNOWN_CONNECTORS: Readonly`, `~/.config` without the variable. */ +export function userConnectorsFile(relative: string, env: NodeJS.ProcessEnv = process.env): string { const base = env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config"); - return path.join(base, "wfpy", "connectors.toml"); + return path.join(base, relative); } /** The project root above `start`: the first directory up with a @@ -97,8 +108,8 @@ export function workspaceRoot(start: string): string | undefined { } } -export function workspaceConnectorsFile(root: string | undefined): string | undefined { - return root ? path.join(root, ".wfpy", "connectors.toml") : undefined; +export function workspaceConnectorsFile(root: string | undefined, relative: string): string | undefined { + return root ? path.join(root, relative) : undefined; } /** The directory the listing is asked for: the source file's. */ @@ -121,22 +132,23 @@ export function loadAcpConnectors(options: LoadAcpConnectorsOptions): AcpConnect command: spec.command, httpApi: spec.httpApi, model: null, mode: null, }); } - const userFile = options.userFile ?? userConnectorsFile(); + const userFile = options.userFile ?? userConnectorsFile(options.files.user); for (const [name, table] of readConnectorsFile(userFile)) { found.set(name, fromTable(name, table, "user", found.get(name), userFile, pathDirs)); } - const wsFile = options.workspaceFile ?? workspaceConnectorsFile(workspaceRoot(options.workspaceDir)); + const wsFile = options.workspaceFile ?? workspaceConnectorsFile(workspaceRoot(options.workspaceDir), options.files.workspace); for (const [name, table] of readConnectorsFile(wsFile)) { found.set(name, fromTable(name, table, "workspace", found.get(name), wsFile!, pathDirs)); } return [...found.values()].sort((a, b) => a.name.localeCompare(b.name)); } -/** The connectors for a workspace directory; `undefined` when a file is - * unreadable (logged), so a caller that only lists falls back to nothing. */ -export async function listAcpConnectors(workspaceDir: string): Promise { +/** The connectors a product's declaration sees for a workspace directory; + * `undefined` when a file is unreadable (logged), so a caller that only + * lists falls back to nothing. */ +export async function listAcpConnectors(config: AcpConnectorConfig, workspaceDir: string): Promise { try { - return loadAcpConnectors({ workspaceDir }); + return loadAcpConnectors({ files: config.files, workspaceDir }); } catch (err) { console.warn("[dialogram] ACP connectors:", err instanceof Error ? err.message : String(err)); return undefined; @@ -157,7 +169,7 @@ export function createAcpAgentResolver( const name = (vscode.workspace .getConfiguration(settingsNamespace, vscode.Uri.file(cwd)) .get(config.settingKey, "") ?? "").trim(); - return resolveChatAgent(name, loadAcpConnectors({ workspaceDir: cwd })); + return resolveChatAgent(name, loadAcpConnectors({ files: config.files, workspaceDir: cwd }), config.files); }; } @@ -165,7 +177,7 @@ export function createAcpAgentResolver( * The agent the chat spawns for the connector a setting names. Throws with * the reason a reader can act on. */ -export function resolveChatAgent(name: string, connectors: AcpConnectorInfo[] | undefined): ChatAgentSpec | undefined { +export function resolveChatAgent(name: string, connectors: AcpConnectorInfo[] | undefined, files?: AcpConnectorFiles): ChatAgentSpec | undefined { const wanted = name.trim() || "opencode"; if (!connectors) { if (wanted === "opencode") return undefined; @@ -174,7 +186,8 @@ export function resolveChatAgent(name: string, connectors: AcpConnectorInfo[] | const found = connectors.find((c) => c.name === wanted); if (!found) { const known = connectors.map((c) => c.name).join(", ") || "none"; - throw new Error(`ACP connector "${wanted}" is not known (known: ${known}); declare it in ${userConnectorsFile()} or the workspace's .wfpy/connectors.toml`); + const where = files ? `; declare it in ${userConnectorsFile(files.user)} or the workspace's ${files.workspace}` : ""; + throw new Error(`ACP connector "${wanted}" is not known (known: ${known})${where}`); } if (!found.available) { throw new Error(`ACP connector "${wanted}" is not available: "${found.command}" is not on the PATH`); diff --git a/packages/extension-core/src/extension/chat/toml-subset.ts b/packages/extension-core/src/extension/chat/toml-subset.ts index 452b2ad..059774e 100644 --- a/packages/extension-core/src/extension/chat/toml-subset.ts +++ b/packages/extension-core/src/extension/chat/toml-subset.ts @@ -1,9 +1,8 @@ /** - * The TOML a connectors file uses (`wfpy`'s `~/.config/wfpy/connectors.toml` - * and `.wfpy/connectors.toml`): tables and dotted keys, strings, booleans, - * numbers, arrays and inline tables, comments. Enough to read what wfpy - * reads, without a dependency; an array of tables (`[[x]]`) is refused by - * name, since the file has no use for one. + * The TOML a connectors file uses: tables and dotted keys, strings, + * booleans, numbers, arrays and inline tables, comments. Enough to read what + * the runtime reads, without a dependency; an array of tables (`[[x]]`) is + * refused by name, since the file has no use for one. */ export type TomlTable = { [key: string]: TomlValue }; export type TomlValue = string | number | boolean | TomlValue[] | TomlTable; diff --git a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts index 9440eaf..d4b65a3 100644 --- a/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts +++ b/packages/extension-core/src/extension/diagram/diagram-editor-provider.ts @@ -792,7 +792,7 @@ export class WorkflowEditorProvider extends GlspEditorProvider { } const dir = workspaceDirFor(vscode.Uri.parse(documentUri).fsPath); const deadline = new Promise((resolve) => setTimeout(() => resolve(undefined), 8000)); - void Promise.race([listAcpConnectors(dir), deadline]) + void Promise.race([listAcpConnectors(acpConnector, dir), deadline]) .then((acpConnectors) => { if (acpConnectors) { const extras: Partial = { acpConnectors }; diff --git a/packages/extension-core/test/acp-connectors.test.ts b/packages/extension-core/test/acp-connectors.test.ts index 0cc9f42..ff6c101 100644 --- a/packages/extension-core/test/acp-connectors.test.ts +++ b/packages/extension-core/test/acp-connectors.test.ts @@ -1,4 +1,4 @@ -// The platform reads the connectors the way wfpy does: the known agents, +// The platform reads the connectors the way the runtime does: the known agents, // available when on the PATH; the user's file; the workspace's file at the // project root, each overriding the last by name. Real files and a real // PATH directory here, so what is tested is the reading, not a mock of it. @@ -15,6 +15,7 @@ import { workspaceRoot } from '../src/extension/chat/acp-connectors'; +const FILES = { user: 'wfpy/connectors.toml', workspace: '.wfpy/connectors.toml' }; let dir: string; let bin: string; function executable(name: string): string { @@ -40,7 +41,7 @@ afterEach(() => { describe('loadAcpConnectors', () => { it('knows the four agents, available when their command is on the PATH', () => { executable('opencode'); - const found = loadAcpConnectors({ workspaceDir: dir, userFile: path.join(dir, 'none'), workspaceFile: path.join(dir, 'none'), pathDirs: [bin] }); + const found = loadAcpConnectors({ files: FILES, workspaceDir: dir, userFile: path.join(dir, 'none'), workspaceFile: path.join(dir, 'none'), pathDirs: [bin] }); expect(found.map(c => [c.name, c.available, c.source, c.command, c.httpApi])).toEqual([ ['claude', false, 'discovered', 'claude-agent-acp', false], ['codex', false, 'discovered', 'codex-acp', false], @@ -70,7 +71,7 @@ mode = "plan" const sub = path.join(root, 'flows', 'deep'); fs.mkdirSync(sub, { recursive: true }); - const found = loadAcpConnectors({ workspaceDir: sub, userFile, pathDirs: [bin] }); + const found = loadAcpConnectors({ files: FILES, workspaceDir: sub, userFile, pathDirs: [bin] }); const by = Object.fromEntries(found.map(c => [c.name, c])); expect(by.claude).toMatchObject({ source: 'user', available: false, command: 'claude-agent-acp', model: 'sonnet', mode: null }); expect(by.mine).toMatchObject({ source: 'workspace', available: true, command: `${mine} --flag`, mode: 'plan', env: { API_KEY: 'k' } }); @@ -80,10 +81,10 @@ mode = "plan" it('names the file and the line when one does not parse, or a connector has no command', () => { const bad = write(path.join(dir, 'bad.toml'), '[connectors.x]\ncommand = "oops'); - expect(() => loadAcpConnectors({ workspaceDir: dir, userFile: bad, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) + expect(() => loadAcpConnectors({ files: FILES, workspaceDir: dir, userFile: bad, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) .toThrow(new RegExp(`${bad.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}: line 2: unterminated string`)); const empty = write(path.join(dir, 'empty.toml'), '[connectors.x]\nmodel = "m"'); - expect(() => loadAcpConnectors({ workspaceDir: dir, userFile: empty, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) + expect(() => loadAcpConnectors({ files: FILES, workspaceDir: dir, userFile: empty, workspaceFile: path.join(dir, 'none'), pathDirs: [] })) .toThrow(/connector 'x' names no command/); }); }); @@ -99,8 +100,20 @@ describe('the files', () => { }); it("locates the user's file under XDG_CONFIG_HOME, else ~/.config", () => { - expect(userConnectorsFile({ XDG_CONFIG_HOME: '/x' })).toBe(path.join('/x', 'wfpy', 'connectors.toml')); - expect(userConnectorsFile({})).toBe(path.join(os.homedir(), '.config', 'wfpy', 'connectors.toml')); + expect(userConnectorsFile(FILES.user, { XDG_CONFIG_HOME: '/x' })).toBe(path.join('/x', 'wfpy', 'connectors.toml')); + expect(userConnectorsFile(FILES.user, {})).toBe(path.join(os.homedir(), '.config', 'wfpy', 'connectors.toml')); + }); + + it('reads the workspace file the declaration names, and no other', () => { + const root = path.join(dir, 'p'); + fs.mkdirSync(path.join(root, '.git'), { recursive: true }); + write(path.join(root, '.wfpy', 'connectors.toml'), '[connectors.a]\ncommand = "a"'); + write(path.join(root, '.other', 'connectors.toml'), '[connectors.b]\ncommand = "b"'); + const names = (files: typeof FILES) => + loadAcpConnectors({ files, workspaceDir: root, userFile: path.join(dir, 'none'), pathDirs: [] }).map(c => c.name); + expect(names(FILES)).toContain('a'); + expect(names(FILES)).not.toContain('b'); + expect(names({ ...FILES, workspace: '.other/connectors.toml' })).toContain('b'); }); }); @@ -119,7 +132,8 @@ describe('resolveChatAgent', () => { it('says why a connector cannot be used', () => { expect(() => resolveChatAgent('claude', listed)).toThrow(/not available.*claude-agent-acp.*PATH/); - expect(() => resolveChatAgent('nope', listed)).toThrow(/not known.*known: claude, mine, opencode/); + expect(() => resolveChatAgent('nope', listed)).toThrow(/not known \(known: claude, mine, opencode\)$/); + expect(() => resolveChatAgent('nope', listed, FILES)).toThrow(/declare it in .*wfpy\/connectors\.toml or the workspace's \.wfpy\/connectors\.toml/); expect(resolveChatAgent('opencode', undefined)).toBeUndefined(); expect(() => resolveChatAgent('claude', undefined)).toThrow(/no ACP connectors could be listed/); }); @@ -132,7 +146,7 @@ describe('resolveChatAgent', () => { const xdg = process.env.XDG_CONFIG_HOME; process.env.XDG_CONFIG_HOME = path.join(dir, 'no-config'); try { - const resolve = createAcpAgentResolver('mlir', { settingKey: 'acp.connector' }); + const resolve = createAcpAgentResolver('mlir', { settingKey: 'acp.connector', files: FILES }); await expect(resolve(dir)).resolves.toMatchObject({ name: 'opencode', argv: ['opencode', 'acp'], httpApi: true }); } finally { if (xdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = xdg; diff --git a/packages/extension-core/test/chat-config-assembly.test.ts b/packages/extension-core/test/chat-config-assembly.test.ts index 6e7317b..1b33574 100644 --- a/packages/extension-core/test/chat-config-assembly.test.ts +++ b/packages/extension-core/test/chat-config-assembly.test.ts @@ -84,7 +84,7 @@ describe('assembleChatRuntimeConfig', () => { }); it('a declared ACP connector becomes the runtime\'s agent resolver; none means opencode', () => { - const declared = assembleChatRuntimeConfig(makeProfile({ acpConnector: { settingKey: 'acp.connector' } }), undefined); + const declared = assembleChatRuntimeConfig(makeProfile({ acpConnector: { settingKey: 'acp.connector', files: { user: 'r/connectors.toml', workspace: '.r/connectors.toml' } } }), undefined); expect(typeof declared.acpAgent).toBe('function'); const plain = assembleChatRuntimeConfig(makeProfile({}), undefined); expect(plain.acpAgent).toBeUndefined(); diff --git a/packages/sidecar-toolkit/src/cli-run-driver.ts b/packages/sidecar-toolkit/src/cli-run-driver.ts index ada3125..dfcb9ae 100644 --- a/packages/sidecar-toolkit/src/cli-run-driver.ts +++ b/packages/sidecar-toolkit/src/cli-run-driver.ts @@ -90,8 +90,8 @@ export interface CliRunDriverConfig { agentToolTimeoutMsSettingKey: string; agentToolRegistrySettingKey: string; agentMcpBridgeCmdSettingKey: string; - /** The ACP connector a run's agents spawn when they name none (wfpy's - * `--acp-connector`, `wfpy connectors` lists them) and what their permission + /** The ACP connector a run's agents spawn when they name none (the runtime's + * `--acp-connector`) and what their permission * requests get when no user answers (`--agent-cli-acp-permissions`); both * optional, a shell without them passes nothing. */ acpConnectorSettingKey?: string; @@ -99,7 +99,7 @@ export interface CliRunDriverConfig { /** Host the run's questions on a Unix socket (`--elicit-socket`): an * agent's `ask_user` question or, over ACP, a permission request reaches * {@link CliRunDriverHost.askUser}, or a VS Code prompt when the host has - * none. Default true; never on Windows, where wfpy's side is AF_UNIX. */ + * none. Default true; never on Windows, where the runtime's side is AF_UNIX. */ elicitSocket?: boolean; runWorkflowCommandId: string; stopWorkflowCommandId: string; @@ -116,8 +116,8 @@ export interface CliRunDriverConfig { }; } -/** A question a running agent puts to the user (wfpy's elicitation channel, - * one JSON object a line on the socket; `SocketElicitationHandler` in wfpy +/** A question a running agent puts to the user (the runtime's elicitation channel, + * one JSON object a line on the socket; `SocketElicitationHandler` in the runtime * documents the wire format). A permission request over ACP arrives as one: * the tool call's title as the question, the agent's options as the choices. */ export interface RunQuestion { @@ -466,13 +466,13 @@ export class CliRunDriver { /** Listen for the run's questions on a fresh Unix socket; returns its path * for `--elicit-socket`. One connection per run, one question at a time - * (wfpy serializes them); each is answered on the same connection as one + * (the runtime serializes them); each is answered on the same connection as one * JSON line, `{id, answer}` or `{id, declined, reason}`. */ private async startElicitSocket(sourceUri: string): Promise { this.stopElicitSocket(); this.elicitSourceUri = sourceUri; // AF_UNIX paths are short (108 bytes on Linux): the tmp dir, not the run dir. - const socketPath = path.join(os.tmpdir(), `wfpy-elicit-${process.pid}-${Date.now().toString(36)}.sock`); + const socketPath = path.join(os.tmpdir(), `acp-elicit-${process.pid}-${Date.now().toString(36)}.sock`); const server = net.createServer((conn) => { conn.setEncoding('utf8'); const lines = readline.createInterface({ input: conn }); diff --git a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts index 4d31c19..bafc133 100644 --- a/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts +++ b/packages/sidecar-toolkit/src/sidecar-diagram-profile.ts @@ -176,13 +176,17 @@ export interface SidecarProfileInput { /** The setting naming the ACP connector (`.`, e.g. * `acp.connector`): the one the chat talks to, offered by the property * panel, and, with `runAcpFlags` on, the run's default (`--acp-connector`). - * The platform reads the connectors itself (the known agents on the - * PATH, wfpy's user and workspace files). */ + * The platform reads the connectors itself: the known agents on the + * PATH, then the runtime's two connectors files, `acpConnectorFiles`. */ acpConnectorSettingKey?: string; + /** The runtime's connectors files, relative: `user` under `~/.config` + * (`$XDG_CONFIG_HOME`), `workspace` under the project root. Required + * with `acpConnectorSettingKey`. */ + acpConnectorFiles?: { user: string; workspace: string }; /** The setting the run driver forwards as `--agent-cli-acp-permissions`. */ acpPermissionsSettingKey?: string; - /** Whether the run driver forwards the two ACP settings to ` run` - * (wfpy's flags). Default true; a product whose run is not wfpy's sets + /** Whether the run driver forwards the two ACP settings to ` run`. + * Default true; a product whose `run` is not the workflow runtime's sets * false, and the connector then serves the chat and the panel only. */ runAcpFlags?: boolean; @@ -417,8 +421,8 @@ export function createSidecarDiagramProfile(input: SidecarProfileInput) { sourceMimeType: 'text/x-python', // The ACP connector: the setting the chat and the property panel // read. Without it the chat keeps its opencode default. - acpConnector: input.acpConnectorSettingKey - ? { settingKey: input.acpConnectorSettingKey } + acpConnector: input.acpConnectorSettingKey && input.acpConnectorFiles + ? { settingKey: input.acpConnectorSettingKey, files: input.acpConnectorFiles } : undefined }, runDriver, diff --git a/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts b/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts index 2e16456..cf6ee43 100644 --- a/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts +++ b/packages/sidecar-toolkit/test/cli-run-driver-elicit.test.ts @@ -143,7 +143,7 @@ describe('CliRunDriver: the ACP connector, the permission policy and the human p expect(args.slice(args.indexOf('--acp-connector'), args.indexOf('--acp-connector') + 2)).toEqual(['--acp-connector', 'claude']); expect(args[args.indexOf('--agent-cli-acp-permissions') + 1]).toBe('reject'); const socketPath = args[args.indexOf('--elicit-socket') + 1]; - expect(socketPath).toMatch(/wfpy-elicit-.*\.sock$/); + expect(socketPath).toMatch(/acp-elicit-.*\.sock$/); expect(fs.existsSync(socketPath)).toBe(true); finishRun!(); await run;