From f3b4baa27548e091394758c6f38bc0b8684db269 Mon Sep 17 00:00:00 2001 From: raiseCatError <315733358+raiseCatError@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:02:25 +0530 Subject: [PATCH] Ask: a shell task reaches command suggestions, not the nearest capability (QA F04) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Physical QA (A12, Ghostty zsh, 68c1cbb7): with Command suggestions set to Local model and Qwen3 0.6B running, `/btw convert every png here to webp` showed the folder listing instead of a suggestion. Reproduced with that model through the real Ask path: Ask's resolver had no notion of a shell task, so it called the request unclear; Auto then let the intent model read it, which answered {"capability":"files.list","confidence":0.99999}, and NMSh answered that capability's canonical "list files". Suggestions only follow what Ask cannot place, so the step never ran. "rename all .jpeg files to .jpg" became "Install which shell?" the same way. The existing test stubbed the intent model to say nothing, which hid it. - Ask recognises shell tasks it has no capability for: changing files in bulk (convert, resize, rename, "turn X into Y"…) and finding them by size, age, duplicates or a count. It says it does not run these itself; archives keep their recipes and destructive requests stay refusals. - A refusal or such an answer is never re-read by the intent model, in Auto or Always. Always could also turn "delete all my files" into a file listing. - Suggestions Off: the answer says where Command suggestions are. A model that declines is named ("suggested no command for this") instead of the answer silently falling back. Tests: tests/askCommandSuggestionsApp.test.ts, the QA model's own answer as the intent stub, Auto and Always, nine safe tasks, and NMSh's own nouns (branch, pane, session, theme) never taken for shell tasks. --- src/app/TerminalApp.ts | 14 +++- src/ask/resolver.ts | 22 +++++- src/ask/types.ts | 3 +- tests/askCommandSuggestionsApp.test.ts | 102 ++++++++++++++++++++++++- 4 files changed, 133 insertions(+), 8 deletions(-) diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts index 2cf3ca81..0c14293c 100644 --- a/src/app/TerminalApp.ts +++ b/src/app/TerminalApp.ts @@ -9736,15 +9736,19 @@ export class TerminalApp { */ private async resolveAsk(text: string, state: AskState): Promise { const outcome = await this.resolveAskOwn(text, state); - if ((outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') || this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) return outcome; + if (outcome.kind !== 'unclear' && outcome.kind !== 'unsupported') return outcome; + if (this.promptConfiguration.askCommandSuggestions !== 'local' || this.askState !== state) { + return outcome.kind === 'unsupported' && outcome.shellTask + ? {...outcome, text: `${outcome.text}\nCommand suggestions (Settings → Ask) can suggest one command from your local model for you to check.`} : outcome; + } if (this.askStage) this.setAskStage('Asking your local model for a command'); const result = await this.understanding.suggestCommand({text, shell: this.shellId, os: process.platform === 'darwin' ? 'macOS' : 'Linux'}); if (this.askState !== state) return outcome; const next = outcome.kind === 'unclear' ? outcome.categories : undefined; if ('unavailable' in result) return {kind: 'answer', capability: 'help.command', text: result.unavailable, ...(next?.length ? {next} : {})}; - // The model declined: Ask's own answer stands. - if (!result.suggestion) return outcome; const model = this.configuration.localUnderstanding.model?.label ?? 'local model'; + // The model declined: Ask's own answer stands, saying so. + if (!result.suggestion) return {...outcome, text: `Your local model (${model}) suggested no command for this. ${outcome.text}`}; return {kind: 'answer', capability: 'help.command', text: 'A suggested command, for you to check before running it yourself.', block: suggestionBlock(result.suggestion, model)}; } @@ -9756,7 +9760,9 @@ export class TerminalApp { const files = systemFileAssistEnvironment(homedir(), context.repoRoot, resolveCommand('python3'), process.execPath, name => resolveCommand(name), commands.install); const deterministic = resolveRequest(text, context, {rejected: state.rejected}, commands, files); const deterministicRoute = () => { this.understanding.lastRoute = {route: 'deterministic', at: Date.now()}; return deterministic; }; - if (!this.understanding.eligible('ask')) return deterministicRoute(); + // A refusal, or something Ask does not do (a shell task), is an answer, not uncertainty: in any mode the intent + // model never re-reads it into the nearest capability it was offered (QA F04). + if (!this.understanding.eligible('ask') || deterministic.kind === 'unsafe' || deterministic.kind === 'unsupported') return deterministicRoute(); // Auto: deterministic first; the model only when it is unsure. Always: the model first, still feeding deterministic builders. const unsure = deterministic.kind === 'unclear' || (deterministic.kind === 'choose' && deterministic.reason === 'ambiguous'); if (!this.understanding.prefersModel && !unsure) return deterministicRoute(); diff --git a/src/ask/resolver.ts b/src/ask/resolver.ts index 8080de29..82d6dee1 100644 --- a/src/ask/resolver.ts +++ b/src/ask/resolver.ts @@ -5,7 +5,7 @@ import type {ShellId} from '../shell/adapters/ShellAdapter.js'; import {CLEAR_LEAD, matchFiles} from './files.js'; import {resolveGit} from './gitAssist.js'; import {browseOutcome, resolveFiles} from './fileAssist.js'; -import {resolveRecipe} from './recipes.js'; +import {matchRecipe, resolveRecipe} from './recipes.js'; import {resolveProject} from './project.js'; import {resolveLocalModel} from './localModel.js'; import {resolveWatch} from './watchAssist.js'; @@ -113,6 +113,23 @@ export const CAPABILITIES: readonly Capability[] = [ const UNSAFE = /\b(?:delete|remove|rm|wipe|erase|purge|destroy|nuke|reset --hard|hard reset|git reset|git clean|clean up untracked|force push|push|commit|chmod|chown|sudo|kill|uninstall|drop|format|overwrite|truncate|rewrite history|rebase|checkout --|discard)\b/u; /** Requests understood as writing or authoring, which Ask has no capability for. */ const AUTHORING = /\b(?:write|create|generate|make|build|compile|refactor|fix|implement|send|email|translate)\b/u; +/** + * Shell tasks Ask has no capability for (QA F04): changing files in bulk (convert, resize, rename…), or finding them by + * size, age or a count. Ask says so itself and never hands them to the optional intent model, which maps them to the + * nearest listed capability ("list files" for "convert every png here to webp"); with command suggestions on, the + * person's local model may suggest one command instead. Archives have recipes, and a destructive request stays a + * refusal (UNSAFE), so neither is matched here. + */ +const FILE_WORDS = String.raw`(?:files?|folders?|director(?:y|ies)|images?|photos?|pictures?|videos?|pngs?|jpe?gs?|gifs?|webps?|heics?|svgs?|pdfs?|csvs?|mp[34]s?|movs?|wavs?|logs?)`; +const SHELL_TASKS = [ + new RegExp(String.raw`^(?:(?:please|can you|could you|help me|i want to|i need to|how (?:do|can|would) i|how to)\s+)*(?:convert|resize|rename|crop|rotate|scale|shrink|optimi[sz]e|minify|transcode|encode|merge|combine|concatenate|split|dedupe|deduplicate|lowercase|uppercase)\b.*\b${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`^(?:please |can you |could you |help me )?turn (?:the |these |those |all |every |my )*(?:\w+ )?${FILE_WORDS} into\b`, 'u'), + new RegExp(String.raw`\b(?:duplicate|identical) ${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`\b${FILE_WORDS}\b.*\b(?:bigger|larger|smaller|older|newer) than\b|\b(?:biggest|largest|smallest|oldest|newest)\b.*\b${FILE_WORDS}\b`, 'u'), + new RegExp(String.raw`\b${FILE_WORDS}\b.*\b(?:changed|modified|edited|created) (?:in the last|since|today|yesterday|this week|last week)\b`, 'u'), + /^(?:please |how (?:do|can) i |how to )?count (?:the )?(?:lines|words|characters|files)\b/u, +]; +export const isShellTask = (text: string): boolean => !UNSAFE.test(text) && SHELL_TASKS.some(pattern => pattern.test(text)); /** Vague references that need context to resolve. */ const VAGUE = /\b(?:thing|that one|the old one|the other one|from earlier|earlier|before|previous one|last one|it again)\b/u; @@ -199,6 +216,9 @@ function resolveExact(raw: string, context: AskContext, state: ResolveState = {} // Homebrew from its own facts; install/upgrade/uninstall are typed proposals behind the final Yes. const packages = resolvePackage(text, context); if (packages) return packages; + if (isShellTask(text) && !matchRecipe(text)) { + return {kind: 'unsupported', shellTask: true, text: 'Ask doesn\'t run shell tasks like this one itself; it finds, opens, shows and switches things in NMSh.'}; + } // Config files and verified edits (resolve → inspect → plan → preview → confirm); removal is answered, never planned. // "find files named config" is a file search, not a config request. if (/^(?:please )?(?:find|locate|search for|look for|show(?: me)?|list)\b.*\b(?:files?|folders?)\s+(?:named|called|matching)\b/u.test(text)) { diff --git a/src/ask/types.ts b/src/ask/types.ts index cce643e5..213feb9d 100644 --- a/src/ask/types.ts +++ b/src/ask/types.ts @@ -159,7 +159,8 @@ export type AskOutcome = direct?: boolean} | {kind: 'answer'; capability: CapabilityId; text: string; follow?: AskOption; block?: CommandBlock; next?: AskOption[]; referents?: AskReferents} | {kind: 'choose'; reason: 'ambiguous' | 'missing'; capability?: CapabilityId; question: string; options: AskOption[]; referents?: AskReferents} - | {kind: 'unsupported'; text: string; alternative?: AskOption} + /** shellTask: a task for a shell command (QA F04), which command suggestions may help with. */ + | {kind: 'unsupported'; text: string; alternative?: AskOption; shellTask?: true} | {kind: 'unsafe'; text: string; alternative?: AskOption; referents?: AskReferents} | {kind: 'unclear'; text: string; categories: AskOption[]}; diff --git a/tests/askCommandSuggestionsApp.test.ts b/tests/askCommandSuggestionsApp.test.ts index 8b7dde3c..38113500 100644 --- a/tests/askCommandSuggestionsApp.test.ts +++ b/tests/askCommandSuggestionsApp.test.ts @@ -16,13 +16,17 @@ function fixture() { const calls: CommandRequest[] = []; let reply: Awaited> = {suggestion: {command: 'find . -type f -size +100M', explanation: 'Files over 100 MB here.'}}; app['understanding'].suggestCommand = async (request: CommandRequest) => { calls.push(request); return reply; }; - app['understanding'].interpretAsk = async () => undefined; + let intent: Awaited> = undefined; + const intents: string[] = []; + app['understanding'].interpretAsk = async (request: {text: string}) => { intents.push(request.text); return intent; }; app['configuration'] = {...app['configuration'], localUnderstanding: {mode: 'auto', ask: true, folding: false, model: {label: 'Qwen3 0.6B', runtime: 'llama.cpp', path: '/m.gguf', owned: true}}}; const state = createAskState(); app['askState'] = state; const ask = (text: string) => app['resolveAsk'](text, state); - return {...frame, app, calls, state, ask, setReply: (value: typeof reply) => { reply = value; }, + return {...frame, app, calls, intents, state, ask, setReply: (value: typeof reply) => { reply = value; }, + setIntent: (value: typeof intent) => { intent = value; }, + setMode: (mode: 'auto' | 'always') => { app['configuration'] = {...app['configuration'], localUnderstanding: {...app['configuration'].localUnderstanding, mode}}; }, enable: () => { app['promptConfiguration'] = {...app['promptConfiguration'], askCommandSuggestions: 'local'}; }}; } @@ -95,3 +99,97 @@ test('a closed Ask never asks the model', async () => { assert.deepEqual(f.calls, []); } finally { f.dispose(); } }); + +/** + * QA F04 (v0.22 physical QA, A12): with the QA model (Qwen3 0.6B) running, `/btw convert every png here to webp` showed + * "Files · " instead of a suggestion. Reproduced with that model: Ask's own resolver called the request unclear, + * so the intent model read it, picked files.list ({"capability":"files.list","confidence":0.99999}), and NMSh answered + * its canonical "list files". The suggestion step only runs for what Ask cannot place, so it never ran. "rename all + * .jpeg files to .jpg" became "Install which shell?" the same way. A shell task (changing files, or finding them by size + * or age) is something Ask itself does not do: it says so, never hands the request to the intent model, and with + * suggestions on asks the person's model for one command instead. + */ +const SHELL_TASKS = ['convert every png here to webp', 'resize all jpgs in this folder to 800px', 'rename all .jpeg files to .jpg', + 'find files bigger than 100mb', 'show me the biggest folders here', 'count lines of typescript in src', 'which files changed in the last day', + 'turn these heic photos into jpgs', 'find duplicate files here']; + +for (const mode of ['auto', 'always'] as const) { + test(`F04 (${mode}): a shell task reaches command suggestions, whatever capability the intent model picks`, async () => { + const f = fixture(); + try { + f.enable(); + f.setMode(mode); + // What the QA model answered for the QA request. + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + for (const text of SHELL_TASKS) { + const before = f.calls.length; + const outcome = await f.ask(text); + assert.ok(outcome.kind === 'answer' && outcome.block, `${text}: ${outcome.kind} ${'capability' in outcome ? outcome.capability : ''}`); + assert.equal(outcome.block.literal, 'find . -type f -size +100M', text); + assert.equal(outcome.block.run, undefined, 'never runnable from Ask'); + assert.equal(f.calls.length, before + 1, `${text}: the local model was asked for a command once`); + assert.equal(f.calls.at(-1)!.text, text); + } + assert.deepEqual(f.intents, [], 'a shell task is never re-read by the intent model'); + } finally { f.dispose(); } + }); +} + +test('F04: with suggestions Off, a shell task says Ask does not run it and where suggestions are; no model is asked', async () => { + const f = fixture(); + try { + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + for (const text of SHELL_TASKS) { + const outcome = await f.ask(text); + assert.equal(outcome.kind, 'unsupported', text); + assert.ok(outcome.kind === 'unsupported'); + assert.match(outcome.text, /Ask doesn't run shell tasks/u, text); + assert.match(outcome.text, /Command suggestions/u, `${text}: says where suggestions are turned on`); + } + assert.deepEqual(f.calls, []); + assert.deepEqual(f.intents, []); + } finally { f.dispose(); } +}); + +test('F04: what Ask does itself is unchanged: archives, listing, finding by name, refusals', async () => { + const f = fixture(); + try { + f.enable(); + f.setIntent({capability: 'files.list', confidence: 0.99999, arguments: {}}); + const archive = await f.ask('compress the logs folder into a tarball'); + assert.ok(archive.kind === 'answer' && archive.block?.argv[0] === 'tar', 'the archive recipe still answers'); + const list = await f.ask('list files'); + assert.ok(list.kind !== 'unsupported' && 'capability' in list && list.capability === 'file.browse'); + const named = await f.ask('find files named package.json'); + assert.ok('capability' in named && named.capability === 'file.find', `find by name: ${named.kind}`); + // NMSh's own things share these verbs: a branch, a pane, a session or a setting is never a shell task. + for (const text of ['rename this branch to release', 'merge main into this branch', 'split the pane', 'turn off the input dividers', + 'show me the newest session', 'convert my theme to light', 'what files are in this folder']) { + const outcome = await f.ask(text); + assert.ok(!(outcome.kind === 'unsupported' && outcome.shellTask), `${text}: not a shell task`); + } + for (const mode of ['auto', 'always'] as const) { + f.setMode(mode); + assert.equal((await f.ask('delete all my files')).kind, 'unsafe', `${mode}: a refusal stands, whatever the intent model says`); + assert.equal((await f.ask('rename all my files to delete them')).kind, 'unsafe', `${mode}: a destructive shell task is refused, not suggested`); + } + assert.deepEqual(f.calls.map(call => call.text).filter(text => !/^(?:split the pane|convert my theme to light)$/u.test(text)), [], + 'none of these reach command suggestions (except what Ask itself says it cannot do)'); + } finally { f.dispose(); } +}); + +test('F04: a declined suggestion says the model had no command, and Ask\'s own answer follows', async () => { + const f = fixture(); + try { + f.enable(); + f.setReply({suggestion: null}); + const task = await f.ask('convert every png here to webp'); + assert.equal(task.kind, 'unsupported'); + assert.ok(task.kind === 'unsupported'); + assert.match(task.text, /^Your local model \(Qwen3 0\.6B\) suggested no command for this\./u); + const vague = await f.ask(VAGUE); + assert.ok(vague.kind === 'unclear'); + assert.match(vague.text, /^Your local model \(Qwen3 0\.6B\) suggested no command for this\./u); + assert.ok(vague.categories.length, 'what Ask can help with is still offered'); + } finally { f.dispose(); } +});