diff --git a/CHANGELOG.md b/CHANGELOG.md index b188a28..8db7622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 0.11.0 + +### Removed + +- **AI Summary feature temporarily removed** — the Copilot-powered AI summary generation has been fully removed pending a safer rollout (see [issue #20](https://github.com/Nimblesite/CommandTree/issues/20)). Reason: the previous activation path could silently fan out one Copilot request per discovered command on install and on every script edit, picking the first available model (often a premium one), which could consume a large share of a user's Copilot quota without warning. The feature will return once request volume is bounded, premium models are gated behind explicit consent, and the user is asked before any Copilot quota is spent. +- `commandtree.enableAiSummaries` and `commandtree.aiModel` settings (no longer wired) +- `commandtree.generateSummaries` and `commandtree.selectModel` commands (no longer wired) +- All Copilot integration code (`semantic/summariser.ts`, `semantic/modelSelection.ts`) and the AI portions of `semantic/summaryPipeline.ts` and `summaryOrchestration.ts` + +### Notes + +- Tooltips still render any summaries already stored in the local SQLite DB; only generation is removed +- Command discovery, registration, tagging, Quick Launch, and execution are unchanged + ## 0.9.0 ### Changed diff --git a/Claude.md b/Claude.md index a67776e..03071d2 100644 --- a/Claude.md +++ b/Claude.md @@ -10,7 +10,7 @@ ## Project Overview -CommandTree is a VS Code extension that discovers and organizes runnable tasks (npm scripts, Makefiles, shell scripts, launch configs, etc.) into a unified tree view sidebar. It supports tagging, quick launch, AI-generated summaries, and 20+ task discovery providers. +CommandTree is a VS Code extension that discovers and organizes runnable tasks (npm scripts, Makefiles, shell scripts, launch configs, etc.) into a unified tree view sidebar. It supports tagging, quick launch, and 20+ task discovery providers. (Copilot-powered AI summaries were temporarily removed in 0.11.0 — see issue #20.) **Primary language(s):** TypeScript **Build command:** `make ci` diff --git a/README.md b/README.md index 8e598aa..a730722 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CommandTree -**One sidebar. Every command. AI-powered.** +**One sidebar. Every command.** **[commandtree.dev](https://commandtree.dev/)** @@ -10,15 +10,12 @@ CommandTree scans your project and surfaces all runnable commands across 22 tool types in a single tree view. Filter by tag, and run in terminal or debugger. -## AI Summaries (powered by GitHub Copilot) +## AI Summaries — temporarily removed -When [GitHub Copilot](https://marketplace.visualstudio.com/items?itemName=GitHub.copilot) is installed, CommandTree automatically generates plain-language summaries of every discovered command. Hover over any command to see what it does, without reading the script. Commands that perform dangerous operations (like `rm -rf` or force-push) are flagged with a security warning. - -Summaries are stored locally and only regenerate when the underlying script changes. +The Copilot-powered AI summary feature has been **temporarily removed** in 0.11.0 while a safer rollout is designed (see [issue #20](https://github.com/Nimblesite/CommandTree/issues/20)). It will return once the extension can guarantee request volume is bounded, premium models are not used silently, and the user is asked before any Copilot quota is spent. ## Features -- **AI Summaries** - GitHub Copilot describes each command in plain language, with security warnings for dangerous operations - **Auto-discovery** - 22 command types including shell scripts, npm, Make, Python, PowerShell, Gradle, Cargo, Maven, Docker Compose, .NET, C# Script, F# Script, Mise, and more - **Quick Launch** - Pin frequently-used commands to a dedicated panel at the top - **Tagging** - Right-click any command to add or remove tags @@ -80,7 +77,6 @@ Open a workspace and the CommandTree panel appears in the sidebar. All discovere | Setting | Description | Default | |---------|-------------|---------| -| `commandtree.enableAiSummaries` | Copilot-powered plain-language summaries and security warnings | `true` | | `commandtree.excludePatterns` | Glob patterns to exclude from discovery | `**/node_modules/**`, `**/.git/**`, etc. | | `commandtree.sortOrder` | Sort commands by `folder`, `name`, or `type` | `folder` | diff --git a/coverage-thresholds.json b/coverage-thresholds.json index 64c8f73..a43cf2f 100644 --- a/coverage-thresholds.json +++ b/coverage-thresholds.json @@ -1,8 +1,8 @@ { "_agent_pmo": "f481f8d", "_doc": "Single source of truth for code coverage thresholds. See REPO-STANDARDS-SPEC [COVERAGE-THRESHOLDS-JSON]. Enforced by tools/check-coverage.mjs via `make test`. Ratchet UP only. Extended format (per-metric) overrides the spec's single default_threshold to enforce both line AND branch coverage per [COVERAGE-THRESHOLDS] (VS Code extension: 80% line / 70% branch — measured values here are well above).", - "lines": 92.11, - "functions": 93.87, - "branches": 87.33, - "statements": 92.11 + "lines": 91.94, + "functions": 93.65, + "branches": 87.04, + "statements": 91.94 } diff --git a/package.json b/package.json index b707f6f..452140b 100644 --- a/package.json +++ b/package.json @@ -130,16 +130,6 @@ "command": "commandtree.makeExecutable", "title": "Make Executable", "icon": "$(unlock)" - }, - { - "command": "commandtree.generateSummaries", - "title": "Generate AI Summaries", - "icon": "$(sparkle)" - }, - { - "command": "commandtree.selectModel", - "title": "CommandTree: Select AI Model", - "icon": "$(hubot)" } ], "menus": { @@ -368,16 +358,6 @@ "Sort by command type, then alphabetically by name" ], "description": "How to sort commands within categories" - }, - "commandtree.enableAiSummaries": { - "type": "boolean", - "default": true, - "description": "Use GitHub Copilot to generate plain-language summaries of scripts, enabling semantic search" - }, - "commandtree.aiModel": { - "type": "string", - "default": "", - "description": "Copilot model ID to use for summaries (e.g. 'gpt-4o-mini'). Leave empty to be prompted on first use." } } }, diff --git a/src/extension.ts b/src/extension.ts index 9161264..db2a733 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,17 +10,11 @@ import { TaskRunner } from "./runners/TaskRunner"; import { QuickTasksProvider } from "./QuickTasksProvider"; import { logger } from "./utils/logger"; import { initDb, disposeDb } from "./db/lifecycle"; -import { forceSelectModel } from "./semantic/summariser"; import { syncTagsFromConfig } from "./tags/tagSync"; import { setupFileWatchers } from "./watchers"; import { PrivateTaskDecorationProvider } from "./tree/PrivateTaskDecorationProvider"; import { appState } from "./state"; -import { - initAiSummaries, - registerDiscoveredCommands, - runSummarisation, - syncAndSummarise, -} from "./summaryOrchestration"; +import { registerDiscoveredCommands, syncAndSummarise } from "./summaryOrchestration"; import type { SummaryDeps } from "./summaryOrchestration"; const MAKE_EXECUTABLE_COMMAND = "commandtree.makeExecutable"; @@ -87,15 +81,11 @@ export async function activate(context: vscode.ExtensionContext): Promise { - initAiSummaries(getSummaryDeps(workspaceRoot)); - }) - .catch((e: unknown) => { - logger.error("Initial discovery failed", { - error: e instanceof Error ? e.message : String(e), - }); + initialDiscovery(workspaceRoot).catch((e: unknown) => { + logger.error("Initial discovery failed", { + error: e instanceof Error ? e.message : String(e), }); + }); } async function initDatabaseSafe(workspaceRoot: string): Promise { @@ -201,24 +191,6 @@ function registerFilterCommands(context: vscode.ExtensionContext): void { vscode.commands.registerCommand("commandtree.clearFilter", () => { getTreeProvider().clearFilters(); updateFilterContext(); - }), - vscode.commands.registerCommand("commandtree.generateSummaries", async () => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspaceRoot !== undefined) { - await runSummarisation(getSummaryDeps(workspaceRoot)); - } - }), - vscode.commands.registerCommand("commandtree.selectModel", async () => { - const result = await forceSelectModel(); - if (result.ok) { - vscode.window.showInformationMessage(`CommandTree: AI model set to ${result.value}`); - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (workspaceRoot !== undefined) { - await runSummarisation(getSummaryDeps(workspaceRoot)); - } - } else { - vscode.window.showWarningMessage(`CommandTree: ${result.error}`); - } }) ); } diff --git a/src/semantic/modelSelection.ts b/src/semantic/modelSelection.ts deleted file mode 100644 index 24c36e8..0000000 --- a/src/semantic/modelSelection.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Pure model selection logic — no vscode dependency. - * Testable outside of the VS Code extension host. - */ - -/** Inline Result type to avoid importing TaskItem (which depends on vscode). */ -type Result = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly error: E }; -const ok = (value: T): Result => ({ ok: true, value }); -const err = (error: E): Result => ({ ok: false, error }); - -/** The "Auto" virtual model ID — not a real endpoint. */ -export const AUTO_MODEL_ID = "auto"; -const NO_MODEL_ERROR = "No Copilot model available after retries"; -const PICKER_CANCELLED_ERROR = "Model selection cancelled"; - -/** Minimal model reference for selection logic. */ -export interface ModelRef { - readonly id: string; - readonly name: string; -} - -/** Dependencies injected into model selection for testability. */ -export interface ModelSelectionDeps { - readonly getSavedId: () => string; - readonly fetchById: (id: string) => Promise; - readonly fetchAll: () => Promise; - readonly promptUser: (models: readonly ModelRef[]) => Promise; - readonly saveId: (id: string) => Promise; -} - -/** - * Resolves a concrete (non-auto) model from a list. - * When preferredId is "auto", picks the first non-auto model. - * When preferredId is specific, finds that exact model. - */ -export function pickConcreteModel(params: { - readonly models: readonly ModelRef[]; - readonly preferredId: string; -}): ModelRef | undefined { - if (params.preferredId === AUTO_MODEL_ID) { - return params.models.find((m) => m.id !== AUTO_MODEL_ID) ?? params.models[0]; - } - return params.models.find((m) => m.id === params.preferredId); -} - -async function findSavedModel(deps: ModelSelectionDeps, savedId: string): Promise { - if (savedId === "") { - return undefined; - } - const exact = await deps.fetchById(savedId); - return exact[0]; -} - -async function fetchAvailableModels(deps: ModelSelectionDeps): Promise> { - const allModels = await deps.fetchAll(); - return allModels.length > 0 ? ok(allModels) : err(NO_MODEL_ERROR); -} - -async function promptAndSaveModel( - deps: ModelSelectionDeps, - models: readonly ModelRef[] -): Promise> { - const picked = await deps.promptUser(models); - if (picked === undefined) { - return err(PICKER_CANCELLED_ERROR); - } - await deps.saveId(picked.id); - return ok(picked); -} - -/** - * Pure model selection logic. Uses saved setting if available, - * otherwise prompts user and persists the choice. - */ -export async function resolveModel(deps: ModelSelectionDeps): Promise> { - const savedId = deps.getSavedId(); - const saved = await findSavedModel(deps, savedId); - if (saved !== undefined) { - return ok(saved); - } - - const allResult = await fetchAvailableModels(deps); - if (!allResult.ok) { - return allResult; - } - return await promptAndSaveModel(deps, allResult.value); -} - -/** - * Pure background model selection. Uses saved setting if valid, - * otherwise chooses an available concrete model without user prompts. - */ -export async function resolveModelAutomatically(deps: ModelSelectionDeps): Promise> { - const saved = await findSavedModel(deps, deps.getSavedId()); - if (saved !== undefined) { - return ok(saved); - } - - const allResult = await fetchAvailableModels(deps); - if (!allResult.ok) { - return allResult; - } - - const automatic = pickConcreteModel({ models: allResult.value, preferredId: AUTO_MODEL_ID }); - return automatic !== undefined ? ok(automatic) : err(NO_MODEL_ERROR); -} diff --git a/src/semantic/summariser.ts b/src/semantic/summariser.ts deleted file mode 100644 index 34cac35..0000000 --- a/src/semantic/summariser.ts +++ /dev/null @@ -1,297 +0,0 @@ -/** - * SPEC: ai-summary-generation - * - * GitHub Copilot integration for generating command summaries. - * Uses VS Code Language Model Tool API for structured output (summary + security warning). - */ -import * as vscode from "vscode"; -import type { Result } from "../models/Result"; -import { ok, err } from "../models/Result"; -import { logger } from "../utils/logger"; -import { resolveModel, resolveModelAutomatically, pickConcreteModel } from "./modelSelection"; -import type { ModelSelectionDeps, ModelRef } from "./modelSelection"; -export type { ModelRef, ModelSelectionDeps } from "./modelSelection"; -export { resolveModel, AUTO_MODEL_ID, pickConcreteModel } from "./modelSelection"; - -const MAX_CONTENT_LENGTH = 4000; -const MODEL_RETRY_COUNT = 10; -const MODEL_RETRY_DELAY_MS = 2000; - -const TOOL_NAME = "report_command_analysis"; - -export interface SummaryResult { - readonly summary: string; - readonly securityWarning: string; -} - -export type ModelSelectionMode = "interactive" | "automatic"; - -const ANALYSIS_TOOL: vscode.LanguageModelChatTool = { - name: TOOL_NAME, - description: "Report the analysis of a command including summary and any security warnings", - inputSchema: { - type: "object", - properties: { - summary: { - type: "string", - description: "Plain-language summary of the command in 1-2 sentences", - }, - securityWarning: { - type: "string", - description: - "Security warning if the command has risks (deletes files, writes credentials, modifies system config, runs untrusted code). Empty string if no risks.", - }, - }, - required: ["summary", "securityWarning"], - }, -}; - -/** - * Waits for a delay (used for retry backoff). - */ -async function delay(ms: number): Promise { - await new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -/** - * Fetches Copilot models with retry, optionally filtering by ID. - */ -async function fetchModels(selector: vscode.LanguageModelChatSelector): Promise { - for (let attempt = 0; attempt < MODEL_RETRY_COUNT; attempt++) { - try { - const models = await vscode.lm.selectChatModels(selector); - if (models.length > 0) { - return models; - } - logger.info("Copilot not ready, retrying", { attempt }); - } catch (e) { - const msg = e instanceof Error ? e.message : "Unknown"; - logger.warn("Model selection error", { attempt, error: msg }); - } - if (attempt < MODEL_RETRY_COUNT - 1) { - await delay(MODEL_RETRY_DELAY_MS); - } - } - return []; -} - -/** - * Formats model metadata for the quickpick detail line. - */ -function formatModelDetail(m: vscode.LanguageModelChat): string { - const tokens = `${Math.round(m.maxInputTokens / 1000)}k tokens`; - const parts = [m.family, m.version, tokens].filter((p) => p !== ""); - return parts.join(" · "); -} - -/** - * Shows a quickpick of all available Copilot models with metadata. - * Returns the chosen model ref, or undefined if cancelled. - */ -async function promptModelPicker( - models: readonly vscode.LanguageModelChat[] -): Promise { - const items = models.map((m) => ({ - label: m.name, - description: m.id, - detail: formatModelDetail(m), - model: m, - })); - const picked = await vscode.window.showQuickPick(items, { - placeHolder: "Select a Copilot model for summarisation", - title: "CommandTree: Choose AI Model", - ignoreFocusOut: true, - matchOnDetail: true, - }); - return picked?.model; -} - -/** - * Builds the standard ModelSelectionDeps wired to VS Code APIs. - */ -function buildVSCodeDeps(): ModelSelectionDeps { - const config = vscode.workspace.getConfiguration("commandtree"); - return { - getSavedId: (): string => config.get("aiModel", ""), - fetchById: async (id: string): Promise => await fetchModels({ vendor: "copilot", id }), - fetchAll: async (): Promise => await fetchModels({ vendor: "copilot" }), - promptUser: async (): Promise => { - const all = await fetchModels({ vendor: "copilot" }); - const picked = await promptModelPicker(all); - return picked !== undefined ? { id: picked.id, name: picked.name } : undefined; - }, - saveId: async (id: string): Promise => { - await config.update("aiModel", id, vscode.ConfigurationTarget.Global); - }, - }; -} - -/** - * Selects the configured model by ID, or prompts the user to pick one. - * When "auto" is selected, uses the Copilot auto model directly. - */ -async function resolveModelRef(mode: ModelSelectionMode | undefined): Promise> { - const deps = buildVSCodeDeps(); - return mode === "automatic" ? await resolveModelAutomatically(deps) : await resolveModel(deps); -} - -function findResolvedModel( - allModels: readonly vscode.LanguageModelChat[], - selectedId: string -): Result { - const model = pickConcreteModel({ - models: allModels.map((m) => ({ id: m.id, name: m.name })), - preferredId: selectedId, - }); - if (!model) { - return err("Selected model no longer available"); - } - - const resolved = allModels.find((m) => m.id === model.id); - if (!resolved) { - return err("Selected model no longer available"); - } - return ok(resolved); -} - -async function fetchResolvedModel(selectedId: string): Promise> { - const allModels = await fetchModels({ vendor: "copilot" }); - if (allModels.length === 0) { - return err("No Copilot models available"); - } - - const result = findResolvedModel(allModels, selectedId); - if (!result.ok) { - return result; - } - logger.info("Resolved model for requests", { - selected: selectedId, - resolved: result.value.id, - }); - return result; -} - -export async function selectCopilotModel( - params: { - readonly mode?: ModelSelectionMode | undefined; - } = {} -): Promise> { - const result = await resolveModelRef(params.mode); - return result.ok ? await fetchResolvedModel(result.value.id) : result; -} - -/** - * Forces the model picker open (ignoring saved setting) and saves the choice. - * Used by the commandtree.selectModel command. - */ -export async function forceSelectModel(): Promise> { - const all = await fetchModels({ vendor: "copilot" }); - if (all.length === 0) { - return err("No Copilot models available"); - } - - const picked = await promptModelPicker(all); - if (picked === undefined) { - return err("Model selection cancelled"); - } - - const config = vscode.workspace.getConfiguration("commandtree"); - await config.update("aiModel", picked.id, vscode.ConfigurationTarget.Global); - logger.info("Model changed via command", { - id: picked.id, - name: picked.name, - }); - return ok(picked.name); -} - -/** - * Extracts the tool call result from the LLM response stream. - */ -async function extractToolCall(response: vscode.LanguageModelChatResponse): Promise { - for await (const part of response.stream) { - if (part instanceof vscode.LanguageModelToolCallPart) { - const input = part.input as Record; - const summary = typeof input["summary"] === "string" ? input["summary"] : ""; - const warning = typeof input["securityWarning"] === "string" ? input["securityWarning"] : ""; - return { summary, securityWarning: warning }; - } - } - return null; -} - -/** - * Sends a chat request with tool calling to get structured output. - */ -async function sendToolRequest( - model: vscode.LanguageModelChat, - prompt: string -): Promise> { - try { - const messages = [vscode.LanguageModelChatMessage.User(prompt)]; - const options: vscode.LanguageModelChatRequestOptions = { - tools: [ANALYSIS_TOOL], - toolMode: vscode.LanguageModelChatToolMode.Required, - }; - const response = await model.sendRequest(messages, options, new vscode.CancellationTokenSource().token); - const result = await extractToolCall(response); - if (result === null) { - return err("No tool call in LLM response"); - } - return ok(result); - } catch (e) { - const message = e instanceof Error ? e.message : "LLM request failed"; - return err(message); - } -} - -/** - * Builds the prompt for script summarisation. - */ -function buildSummaryPrompt(params: { - readonly type: string; - readonly label: string; - readonly command: string; - readonly content: string; -}): string { - const truncated = - params.content.length > MAX_CONTENT_LENGTH ? params.content.substring(0, MAX_CONTENT_LENGTH) : params.content; - - return [ - `Analyse this ${params.type} command. Provide a plain-language summary (1-2 sentences).`, - `If the command has security risks (writes credentials, deletes files, modifies system config, runs untrusted code, etc.), describe the risk. Otherwise leave securityWarning empty.`, - `Name: ${params.label}`, - `Command: ${params.command}`, - "", - "Script content:", - truncated, - ].join("\n"); -} - -/** - * Generates a structured summary for a script via Copilot tool calling. - */ -export async function summariseScript(params: { - readonly model: vscode.LanguageModelChat; - readonly label: string; - readonly type: string; - readonly command: string; - readonly content: string; -}): Promise> { - const prompt = buildSummaryPrompt(params); - const result = await sendToolRequest(params.model, prompt); - - if (!result.ok) { - logger.error("Summarisation failed", { - label: params.label, - error: result.error, - }); - return result; - } - if (result.value.summary === "") { - return err("Empty summary returned"); - } - - return result; -} diff --git a/src/semantic/summaryPipeline.ts b/src/semantic/summaryPipeline.ts index 14258cf..a806d1c 100644 --- a/src/semantic/summaryPipeline.ts +++ b/src/semantic/summaryPipeline.ts @@ -1,34 +1,15 @@ /** - * SPEC: ai-summary-generation - * - * Summary pipeline: generates Copilot summaries and stores them in SQLite. + * Registers discovered commands and their content hashes in SQLite. + * AI summary generation has been removed (issue #20): no Copilot calls remain. */ -import type * as vscode from "vscode"; import type { CommandItem } from "../models/TaskItem"; import { ok, err } from "../models/Result"; import type { Result } from "../models/Result"; -import { logger } from "../utils/logger"; -import { computeContentHash } from "../db/db"; +import { computeContentHash, registerCommand } from "../db/db"; import type { FileSystemAdapter } from "./adapters"; -import type { SummaryResult } from "./summariser"; -import type { ModelSelectionMode } from "./summariser"; -import { selectCopilotModel, summariseScript } from "./summariser"; -import { initDb, getDb } from "../db/lifecycle"; -import { upsertSummary, getRow, registerCommand } from "../db/db"; -import type { DbHandle } from "../db/db"; +import { initDb } from "../db/lifecycle"; -const MAX_CONSECUTIVE_FAILURES = 3; - -interface PendingItem { - readonly task: CommandItem; - readonly content: string; - readonly hash: string; -} - -/** - * Reads script content for a task using the provided file system adapter. - */ async function readTaskContent(params: { readonly task: CommandItem; readonly fs: FileSystemAdapter; @@ -37,76 +18,6 @@ async function readTaskContent(params: { return result.ok ? result.value : params.task.command; } -/** - * Finds tasks that need a new or updated summary. - */ -async function findPendingSummaries(params: { - readonly handle: DbHandle; - readonly tasks: readonly CommandItem[]; - readonly fs: FileSystemAdapter; -}): Promise { - const pending: PendingItem[] = []; - for (const task of params.tasks) { - const content = await readTaskContent({ task, fs: params.fs }); - const hash = computeContentHash(content); - const existing = getRow({ handle: params.handle, commandId: task.id }); - const needsSummary = existing === undefined || existing.summary === "" || existing.contentHash !== hash; - if (needsSummary) { - pending.push({ task, content, hash }); - } - } - return pending; -} - -/** - * Gets a summary for a task via Copilot. - * NO FALLBACK. If Copilot is unavailable, returns null. - */ -async function getSummary(params: { - readonly model: vscode.LanguageModelChat; - readonly task: CommandItem; - readonly content: string; -}): Promise { - const result = await summariseScript({ - model: params.model, - label: params.task.label, - type: params.task.type, - command: params.task.command, - content: params.content, - }); - return result.ok ? result.value : null; -} - -/** - * Summarises a single task and stores the summary in SQLite. - */ -async function processOneSummary(params: { - readonly model: vscode.LanguageModelChat; - readonly task: CommandItem; - readonly content: string; - readonly hash: string; - readonly handle: DbHandle; -}): Promise> { - const result = await getSummary(params); - if (result === null) { - return err("Copilot summary failed"); - } - - const warning = result.securityWarning === "" ? null : result.securityWarning; - upsertSummary({ - handle: params.handle, - commandId: params.task.id, - contentHash: params.hash, - summary: result.summary, - securityWarning: warning, - }); - return ok(undefined); -} - -/** - * Registers all discovered commands in SQLite with their content hashes. - * Does NOT require Copilot. Preserves existing summaries. - */ export async function registerAllCommands(params: { readonly tasks: readonly CommandItem[]; readonly workspaceRoot: string; @@ -131,139 +42,3 @@ export async function registerAllCommands(params: { } return ok(registered); } - -interface BatchState { - succeeded: number; - failed: number; - aborted: boolean; -} - -/** - * Processes one pending item and updates the batch state. - */ -async function processPendingItem(params: { - readonly item: PendingItem; - readonly model: vscode.LanguageModelChat; - readonly handle: DbHandle; - readonly state: BatchState; -}): Promise { - const result = await processOneSummary({ - model: params.model, - task: params.item.task, - content: params.item.content, - hash: params.item.hash, - handle: params.handle, - }); - if (result.ok) { - params.state.succeeded++; - return; - } - params.state.failed++; - logger.error("[SUMMARY] Task failed", { - id: params.item.task.id, - error: result.error, - }); - if (params.state.failed >= MAX_CONSECUTIVE_FAILURES) { - logger.error("[SUMMARY] Too many failures, aborting", { failed: params.state.failed }); - params.state.aborted = true; - } -} - -async function getSummaryDbHandle(params: { - readonly tasks: readonly CommandItem[]; - readonly workspaceRoot: string; - readonly fs: FileSystemAdapter; -}): Promise> { - const regResult = await registerAllCommands(params); - if (!regResult.ok) { - logger.error("[SUMMARY] registerAllCommands failed", { error: regResult.error }); - return err(regResult.error); - } - const dbResult = getDb(); - return dbResult.ok ? ok(dbResult.value) : err(dbResult.error); -} - -async function selectSummaryModel( - mode: ModelSelectionMode | undefined -): Promise> { - const modelResult = await selectCopilotModel({ mode }); - if (!modelResult.ok) { - logger.error("[SUMMARY] Copilot model selection failed", { error: modelResult.error }); - } - return modelResult; -} - -async function processPendingBatch(params: { - readonly pending: readonly PendingItem[]; - readonly model: vscode.LanguageModelChat; - readonly handle: DbHandle; - readonly onProgress?: ((done: number, total: number, label: string) => void) | undefined; -}): Promise { - const state: BatchState = { succeeded: 0, failed: 0, aborted: false }; - for (const item of params.pending) { - await processPendingItem({ item, model: params.model, handle: params.handle, state }); - params.onProgress?.(state.succeeded + state.failed, params.pending.length, item.task.label); - if (state.aborted) { - break; - } - } - return state; -} - -function batchStateToResult(state: BatchState): Result { - logger.info("[SUMMARY] complete", { succeeded: state.succeeded, failed: state.failed }); - if (state.succeeded === 0 && state.failed > 0) { - return err(`All ${state.failed} tasks failed to summarise`); - } - return ok(state.succeeded); -} - -async function runPendingSummaries(params: { - readonly tasks: readonly CommandItem[]; - readonly fs: FileSystemAdapter; - readonly handle: DbHandle; - readonly model: vscode.LanguageModelChat; - readonly onProgress?: ((done: number, total: number, label: string) => void) | undefined; -}): Promise> { - const pending = await findPendingSummaries({ handle: params.handle, tasks: params.tasks, fs: params.fs }); - if (pending.length === 0) { - logger.info("[SUMMARY] All summaries up to date"); - return ok(0); - } - const state = await processPendingBatch({ - pending, - model: params.model, - handle: params.handle, - onProgress: params.onProgress, - }); - return batchStateToResult(state); -} - -/** - * Summarises all tasks that are new or have changed content. - * Stores summaries in SQLite. - * Commands are registered in DB BEFORE Copilot is contacted. - */ -export async function summariseAllTasks(params: { - readonly tasks: readonly CommandItem[]; - readonly workspaceRoot: string; - readonly fs: FileSystemAdapter; - readonly modelSelectionMode?: ModelSelectionMode | undefined; - readonly onProgress?: (done: number, total: number, label: string) => void; -}): Promise> { - const handleResult = await getSummaryDbHandle(params); - if (!handleResult.ok) { - return handleResult; - } - const modelResult = await selectSummaryModel(params.modelSelectionMode); - if (!modelResult.ok) { - return modelResult; - } - return await runPendingSummaries({ - tasks: params.tasks, - fs: params.fs, - handle: handleResult.value, - model: modelResult.value, - onProgress: params.onProgress, - }); -} diff --git a/src/summaryOrchestration.ts b/src/summaryOrchestration.ts index 1a4662e..1824ce6 100644 --- a/src/summaryOrchestration.ts +++ b/src/summaryOrchestration.ts @@ -1,16 +1,12 @@ /** - * SPEC: SPEC-AI-010, SPEC-AI-030 - * Coordinates automatic and user-triggered AI summary runs. + * Registers discovered commands in SQLite and refreshes views after task changes. + * AI summary generation has been removed (issue #20): no Copilot calls happen here. */ -import * as vscode from "vscode"; import type { CommandTreeProvider } from "./CommandTreeProvider"; import type { QuickTasksProvider } from "./QuickTasksProvider"; -import type { Result } from "./models/Result"; -import { ok } from "./models/Result"; import { logger } from "./utils/logger"; -import { summariseAllTasks, registerAllCommands } from "./semantic/summaryPipeline"; +import { registerAllCommands } from "./semantic/summaryPipeline"; import { createVSCodeFileSystem } from "./semantic/vscodeAdapters"; -import type { ModelSelectionMode } from "./semantic/summariser"; export interface SummaryDeps { readonly workspaceRoot: string; @@ -18,38 +14,6 @@ export interface SummaryDeps { readonly quickTasksProvider: QuickTasksProvider; } -interface RunSummaryParams extends SummaryDeps { - readonly modelSelectionMode?: ModelSelectionMode | undefined; -} - -function aiSummariesEnabled(): boolean { - const aiConfig = vscode.workspace.getConfiguration("commandtree").get("enableAiSummaries"); - return aiConfig !== false; -} - -async function refreshSummaryViews(params: SummaryDeps): Promise { - await params.treeProvider.refresh(); - params.quickTasksProvider.updateTasks(params.treeProvider.getAllTasks()); -} - -async function summariseCurrentTasks(params: RunSummaryParams): Promise> { - const tasks = params.treeProvider.getAllTasks(); - logger.info("[SUMMARY] Starting", { taskCount: tasks.length }); - if (tasks.length === 0) { - logger.warn("[SUMMARY] No tasks to summarise"); - return ok(0); - } - return await summariseAllTasks({ - tasks, - workspaceRoot: params.workspaceRoot, - fs: createVSCodeFileSystem(), - modelSelectionMode: params.modelSelectionMode, - onProgress: (done, total, label) => { - logger.info(`[SUMMARY] ${label}`, { done, total }); - }, - }); -} - export async function registerDiscoveredCommands(params: SummaryDeps): Promise { const tasks = params.treeProvider.getAllTasks(); if (tasks.length === 0) { @@ -67,36 +31,8 @@ export async function registerDiscoveredCommands(params: SummaryDeps): Promise { - logger.error("AI summarisation failed", { - error: e instanceof Error ? e.message : "Unknown", - }); - }); -} - -export async function runSummarisation(params: RunSummaryParams): Promise { - const summaryResult = await summariseCurrentTasks(params); - if (!summaryResult.ok) { - logger.error("Summary pipeline failed", { error: summaryResult.error }); - vscode.window.showErrorMessage(`CommandTree: Summary failed — ${summaryResult.error}`); - return; - } - if (summaryResult.value > 0) { - await refreshSummaryViews(params); - } - vscode.window.showInformationMessage(`CommandTree: Summarised ${summaryResult.value} commands`); -} - export async function syncAndSummarise(params: SummaryDeps): Promise { await params.treeProvider.refresh(); params.quickTasksProvider.updateTasks(params.treeProvider.getAllTasks()); await registerDiscoveredCommands(params); - if (aiSummariesEnabled()) { - await runSummarisation({ ...params, modelSelectionMode: "automatic" }); - } } diff --git a/src/test/e2e/aisummaries.e2e.test.ts b/src/test/e2e/aisummaries.e2e.test.ts deleted file mode 100644 index f340876..0000000 --- a/src/test/e2e/aisummaries.e2e.test.ts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * SPEC: ai-summary-generation - * AI SUMMARIES E2E TESTS - * - * These tests verify that the Copilot integration ACTUALLY WORKS: - * - Copilot authenticates successfully - * - Summaries are generated for discovered commands - * - Summary data appears on task items in the tree - * - * If Copilot auth fails (GitHubLoginFailed), these tests MUST FAIL. - */ - -import * as assert from "assert"; -import * as vscode from "vscode"; -import { - activateExtension, - sleep, - getCommandTreeProvider, - collectLeafTasks, - getTooltipText, - collectLeafItems, -} from "../helpers/helpers"; - -suite("AI Summary E2E Tests", () => { - suiteSetup(async function () { - this.timeout(30000); - await activateExtension(); - await sleep(2000); - }); - - suite("Copilot Integration", () => { - test("generateSummaries command is registered", async function () { - this.timeout(10000); - const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("commandtree.generateSummaries"), "generateSummaries command must be registered"); - }); - - test("selectModel command is registered", async function () { - this.timeout(10000); - const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("commandtree.selectModel"), "selectModel command must be registered"); - }); - - test("@exclude-ci Copilot models are available", async function () { - this.timeout(30000); - const models = await vscode.lm.selectChatModels({ vendor: "copilot" }); - assert.ok(models.length > 0, "At least one Copilot model must be available — is GitHub Copilot authenticated?"); - }); - - test("@exclude-ci multiple Copilot models are available for user to pick from", async function () { - this.timeout(30000); - const models = await vscode.lm.selectChatModels({ vendor: "copilot" }); - assert.ok( - models.length >= 1, - `Model picker needs models to show the user — got ${models.length}. Is GitHub Copilot authenticated?` - ); - // Every model must have an id and name for the picker to display - for (const m of models) { - assert.ok(m.id.length > 0, `Model must have an id — got empty string for "${m.name}"`); - assert.ok(m.name.length > 0, `Model must have a name — got empty string for "${m.id}"`); - } - }); - - test("@exclude-ci setting aiModel config selects that model for summarisation", async function () { - this.timeout(120000); - const models = await vscode.lm.selectChatModels({ vendor: "copilot" }); - assert.ok(models.length > 0, "Need at least one Copilot model — is GitHub Copilot authenticated?"); - const firstModel = models[0]; - if (firstModel === undefined) { - assert.fail("First model must exist"); - } - - // Set the model via config (same way the picker persists it) - const config = vscode.workspace.getConfiguration("commandtree"); - await config.update("aiModel", firstModel.id, vscode.ConfigurationTarget.Global); - - // Verify it persisted - const savedId = config.get("aiModel", ""); - assert.strictEqual(savedId, firstModel.id, "aiModel config must persist the chosen model ID"); - - // Run summarisation — it should use the configured model without prompting - await vscode.commands.executeCommand("commandtree.generateSummaries"); - await sleep(10000); - - // If we got here without a QuickPick blocking, the saved model was used - const provider = getCommandTreeProvider(); - const tasks = await collectLeafTasks(provider); - const withSummary = tasks.filter((t) => t.summary !== undefined && t.summary !== ""); - assert.ok( - withSummary.length > 0, - `Summarisation with model "${firstModel.id}" must produce results — got 0/${tasks.length}` - ); - - // Clean up — reset to empty so other tests aren't affected - await config.update("aiModel", "", vscode.ConfigurationTarget.Global); - }); - - test("aiModel config is empty by default so user gets prompted", async function () { - this.timeout(10000); - const config = vscode.workspace.getConfiguration("commandtree"); - // Reset to default - await config.update("aiModel", undefined, vscode.ConfigurationTarget.Global); - const savedId = config.get("aiModel", ""); - assert.strictEqual(savedId, "", "aiModel must default to empty string (triggers picker on first use)"); - }); - - test("@exclude-ci generateSummaries produces actual summaries on tasks", async function () { - this.timeout(120000); - const provider = getCommandTreeProvider(); - const tasksBefore = await collectLeafTasks(provider); - assert.ok(tasksBefore.length > 0, "Must have discovered tasks to summarise"); - - // Run the generate summaries command - await vscode.commands.executeCommand("commandtree.generateSummaries"); - - // Wait for summarisation to complete and refresh - await sleep(10000); - await vscode.commands.executeCommand("commandtree.refresh"); - await sleep(2000); - - const tasksAfter = await collectLeafTasks(provider); - const withSummary = tasksAfter.filter((t) => t.summary !== undefined && t.summary !== ""); - - assert.ok( - withSummary.length > 0, - `Copilot must generate at least one summary — got 0 out of ${tasksAfter.length} tasks. ` + - "If Copilot auth failed (GitHubLoginFailed), that is the root cause." - ); - }); - - test("@exclude-ci summaries appear in tree item tooltips", async function () { - this.timeout(120000); - const provider = getCommandTreeProvider(); - - // Ensure summaries have been generated (may already be done by previous test) - await vscode.commands.executeCommand("commandtree.generateSummaries"); - await sleep(10000); - await vscode.commands.executeCommand("commandtree.refresh"); - await sleep(2000); - - const items = await collectLeafItems(provider); - const withTooltipSummary = items.filter((item) => { - const tooltip = getTooltipText(item); - // Summaries appear as blockquotes in the tooltip markdown - return tooltip.includes("> "); - }); - - assert.ok(withTooltipSummary.length > 0, "At least one tree item must have a summary in its tooltip"); - }); - - test("@exclude-ci security warnings are surfaced in tree labels", async function () { - this.timeout(120000); - const provider = getCommandTreeProvider(); - - // After summaries are generated, any task with security risks - // should have the warning emoji in the label - await vscode.commands.executeCommand("commandtree.generateSummaries"); - await sleep(10000); - await vscode.commands.executeCommand("commandtree.refresh"); - await sleep(2000); - - const tasks = await collectLeafTasks(provider); - const withWarning = tasks.filter((t) => t.securityWarning !== undefined && t.securityWarning !== ""); - - // Not all tasks will have warnings, but if any do, verify they show in tooltips - if (withWarning.length > 0) { - const items = await collectLeafItems(provider); - const warningItems = items.filter((item) => { - const tooltip = getTooltipText(item); - return tooltip.includes("Security Warning"); - }); - assert.ok(warningItems.length > 0, "Tasks with security warnings must show warning in tooltip"); - } - }); - }); -}); diff --git a/src/test/e2e/summaryTooltip.e2e.test.ts b/src/test/e2e/summaryTooltip.e2e.test.ts index 31ede6a..938cdcb 100644 --- a/src/test/e2e/summaryTooltip.e2e.test.ts +++ b/src/test/e2e/summaryTooltip.e2e.test.ts @@ -1,10 +1,8 @@ /** * Exercises the summary + security-warning rendering branches in the tree: * createCommandNode label prefix, buildTooltip warning/summary sections, - * and CommandTreeProvider.attachSummaries wiring. - * - * A real AI pipeline only runs with Copilot auth (excluded from CI), so this - * test seeds the SQLite summary row directly via the DB's public API. + * and CommandTreeProvider.attachSummaries wiring. Seeds the summary row + * directly via the DB's public API — AI generation has been removed (#20). */ import * as assert from "assert"; diff --git a/src/test/e2e/treeview.e2e.test.ts b/src/test/e2e/treeview.e2e.test.ts index e4e334e..7322f0d 100644 --- a/src/test/e2e/treeview.e2e.test.ts +++ b/src/test/e2e/treeview.e2e.test.ts @@ -14,7 +14,6 @@ import { getCommandTreeProvider, getLabelString, collectLeafItems, - collectLeafTasks, refreshTasks, writeFile, deleteFile, @@ -232,23 +231,6 @@ suite("TreeView E2E Tests", () => { }); }); - suite("AI Summaries", () => { - test("@exclude-ci Copilot summarisation produces summaries for discovered tasks", async function () { - this.timeout(15000); - const provider = getCommandTreeProvider(); - // AI summaries: extension activation triggers summarisation via Copilot. - // If Copilot auth fails (GitHubLoginFailed), tasks will have no summaries. - // This MUST fail if the integration is broken. - const allTasks = await collectLeafTasks(provider); - const withSummary = allTasks.filter((t) => t.summary !== undefined && t.summary !== ""); - assert.ok( - withSummary.length > 0, - `Copilot summarisation must produce summaries — got 0 out of ${allTasks.length} tasks. ` + - "Check for GitHubLoginFailed errors above." - ); - }); - }); - suite("Private Make And Mise Tasks", () => { const makeRelativePath = "private-targets/Makefile"; const miseRelativePath = "private-targets/mise.toml"; diff --git a/src/test/unit/modelSelection.unit.test.ts b/src/test/unit/modelSelection.unit.test.ts deleted file mode 100644 index 103783c..0000000 --- a/src/test/unit/modelSelection.unit.test.ts +++ /dev/null @@ -1,181 +0,0 @@ -import * as assert from "assert"; -import { - pickConcreteModel, - resolveModel, - resolveModelAutomatically, - AUTO_MODEL_ID, -} from "../../semantic/modelSelection"; -import type { ModelRef, ModelSelectionDeps } from "../../semantic/modelSelection"; - -/** - * PURE UNIT TESTS for model selection logic. - * Tests pickConcreteModel and resolveModel — no VS Code dependency. - * SPEC: SPEC-AI-030 - */ -suite("Model Selection Unit Tests", () => { - const GPT4: ModelRef = { id: "gpt-4o", name: "GPT-4o" }; - const CLAUDE: ModelRef = { id: "claude-sonnet", name: "Claude Sonnet" }; - const AUTO: ModelRef = { id: AUTO_MODEL_ID, name: "Auto" }; - - suite("pickConcreteModel", () => { - test("returns specific model when preferredId matches", () => { - const result = pickConcreteModel({ - models: [GPT4, CLAUDE], - preferredId: "claude-sonnet", - }); - if (result === undefined) { - assert.fail("Expected a model but got undefined"); - } - assert.strictEqual(result.id, "claude-sonnet"); - assert.strictEqual(result.name, "Claude Sonnet"); - }); - - test("returns undefined when preferredId not found", () => { - const result = pickConcreteModel({ - models: [GPT4, CLAUDE], - preferredId: "nonexistent-model", - }); - assert.strictEqual(result, undefined); - }); - - test("auto picks first non-auto model", () => { - const result = pickConcreteModel({ - models: [AUTO, GPT4, CLAUDE], - preferredId: AUTO_MODEL_ID, - }); - assert.strictEqual(result?.id, "gpt-4o"); - }); - - test("auto falls back to first model if all are auto", () => { - const result = pickConcreteModel({ - models: [AUTO], - preferredId: AUTO_MODEL_ID, - }); - assert.strictEqual(result?.id, AUTO_MODEL_ID); - }); - - test("returns undefined for empty model list", () => { - const result = pickConcreteModel({ - models: [], - preferredId: "gpt-4o", - }); - assert.strictEqual(result, undefined); - }); - - test("auto with empty list returns undefined", () => { - const result = pickConcreteModel({ - models: [], - preferredId: AUTO_MODEL_ID, - }); - assert.strictEqual(result, undefined); - }); - }); - - suite("resolveModel", () => { - const createDeps = (overrides: Partial = {}): ModelSelectionDeps => ({ - getSavedId: (): string => "", - fetchById: async (): Promise => await Promise.resolve([]), - fetchAll: async (): Promise => await Promise.resolve([GPT4, CLAUDE]), - promptUser: async (models: readonly ModelRef[]): Promise => - await Promise.resolve(models[0]), - saveId: async (): Promise => { - await Promise.resolve(); - }, - ...overrides, - }); - - test("uses saved model ID when it exists and fetches successfully", async () => { - const deps = createDeps({ - getSavedId: (): string => "claude-sonnet", - fetchById: async (): Promise => await Promise.resolve([CLAUDE]), - }); - const result = await resolveModel(deps); - assert.ok(result.ok); - assert.strictEqual(result.value.id, "claude-sonnet"); - }); - - test("prompts user when no saved ID", async () => { - let prompted = false; - const deps = createDeps({ - getSavedId: (): string => "", - promptUser: async (models: readonly ModelRef[]): Promise => { - prompted = true; - return await Promise.resolve(models[0]); - }, - }); - const result = await resolveModel(deps); - assert.ok(result.ok); - assert.ok(prompted, "User must be prompted when no saved ID"); - }); - - test("prompts user when saved ID no longer available", async () => { - let prompted = false; - const deps = createDeps({ - getSavedId: (): string => "deleted-model", - fetchById: async (): Promise => await Promise.resolve([]), - promptUser: async (models: readonly ModelRef[]): Promise => { - prompted = true; - return await Promise.resolve(models[0]); - }, - }); - const result = await resolveModel(deps); - assert.ok(result.ok); - assert.ok(prompted, "User must be prompted when saved model is gone"); - }); - - test("saves the user's choice after prompting", async () => { - let savedId = ""; - const deps = createDeps({ - promptUser: async (): Promise => await Promise.resolve(CLAUDE), - saveId: async (id: string): Promise => { - savedId = id; - await Promise.resolve(); - }, - }); - const result = await resolveModel(deps); - assert.ok(result.ok); - assert.strictEqual(savedId, "claude-sonnet", "Chosen model ID must be persisted"); - }); - - test("returns error when user cancels picker", async () => { - const deps = createDeps({ - promptUser: async (): Promise => { - await Promise.resolve(); - return undefined; - }, - }); - const result = await resolveModel(deps); - assert.ok(!result.ok); - assert.strictEqual(result.error, "Model selection cancelled"); - }); - - test("returns error when no models available", async () => { - const deps = createDeps({ - fetchAll: async (): Promise => await Promise.resolve([]), - }); - const result = await resolveModel(deps); - assert.ok(!result.ok); - assert.strictEqual(result.error, "No Copilot model available after retries"); - }); - - test("automatic selection picks a concrete model without prompting", async () => { - let prompted = false; - let savedId = ""; - const deps = createDeps({ - promptUser: async (): Promise => { - prompted = true; - return await Promise.resolve(CLAUDE); - }, - saveId: async (id: string): Promise => { - savedId = id; - await Promise.resolve(); - }, - }); - const result = await resolveModelAutomatically(deps); - assert.ok(result.ok); - assert.strictEqual(result.value.id, "gpt-4o"); - assert.strictEqual(prompted, false, "Automatic background selection must not open the picker"); - assert.strictEqual(savedId, "", "Automatic background selection must not persist an implicit choice"); - }); - }); -});