diff --git a/ts/.vscode/settings.json b/ts/.vscode/settings.json index dec86f76d..2ff2419e6 100644 --- a/ts/.vscode/settings.json +++ b/ts/.vscode/settings.json @@ -3,6 +3,7 @@ "cSpell.words": [ "aiclient", "AUTOINCREMENT", + "behaviour", "Chunker", "Exif", "exifreader", diff --git a/ts/CLAUDE.md b/ts/CLAUDE.md index a428dfc94..d34aa1e5b 100644 --- a/ts/CLAUDE.md +++ b/ts/CLAUDE.md @@ -7,9 +7,12 @@ This is a **pnpm monorepo** rooted at `ts/`. All commands run from the `ts/` dir ```bash # Install & build pnpm i -pnpm run build # Uses fluid-build to build all packages +pnpm run build # Uses fluid-build to build all packages pnpm run build:shell # Build only the shell app and its dependencies +# Clean +pnpm run clean # works at the root and per package + # Test pnpm run test:local # All unit tests (*.spec.ts) across packages pnpm run test:live # Integration tests (*.test.ts) — requires API keys diff --git a/ts/packages/actionGrammar/README.md b/ts/packages/actionGrammar/README.md index 8118b8321..b47a8ebce 100644 --- a/ts/packages/actionGrammar/README.md +++ b/ts/packages/actionGrammar/README.md @@ -131,7 +131,7 @@ npm run test:integration | --------------------------- | ------------------------------------------------------------------------------------- | | `action-schema` (workspace) | Reading `.pas.json` schema files for checked-variable enrichment | | `debug` | Debug logging (`typeagent:grammar:*`, `typeagent:nfa:*`, `typeagent:actionGrammar:*`) | -| `regexp.escape` | Safe regex escaping for the legacy matcher | +| `regexp.escape` | Safe regex escaping for the simple recursive backtracking matcher | ## Trademarks diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index a424ee0cb..6d0fae207 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -14,6 +14,13 @@ import { VarStringPart, } from "./grammarTypes.js"; +// Separator mode for completion results. Structurally identical to +// SeparatorMode from @typeagent/agent-sdk; independently defined here so +// actionGrammar does not depend on agentSdk. The grammar matcher only +// produces "spacePunctuation", "optional", and "none" — never "space" +// (which is strictly command/flag-level). +type SeparatorMode = "space" | "spacePunctuation" | "optional" | "none"; + const debugMatchRaw = registerDebug("typeagent:grammar:match"); const debugCompletion = registerDebug("typeagent:grammar:completion"); @@ -21,7 +28,7 @@ const debugCompletion = registerDebug("typeagent:grammar:completion"); const separatorRegExpStr = "\\s\\p{P}"; const separatorRegExp = new RegExp(`[${separatorRegExpStr}]+`, "yu"); const wildcardTrimRegExp = new RegExp( - `[${separatorRegExpStr}]*(.+?)[${separatorRegExpStr}]*$`, + `[${separatorRegExpStr}]*([^${separatorRegExpStr}](?:.*[^${separatorRegExpStr}])?)[${separatorRegExpStr}]*$`, "yu", ); @@ -45,8 +52,9 @@ const digitRe = /[0-9]/; // while still requiring them when both sides use word spaces // (e.g. Latin followed by Latin). function isWordBoundaryScript(c: string): boolean { - // Fast path: all ASCII letters are Latin-script (boundary required). - // ASCII digits and punctuation/space fall through to return false here + // Fast path: all ASCII characters are handled here without the regex. + // ASCII letters are Latin-script (boundary required). + // ASCII digits, punctuation, and space return false // (digits are handled separately by digitRe, punctuation/space never need a boundary). const code = c.charCodeAt(0); if (code < 128) { @@ -54,7 +62,7 @@ function isWordBoundaryScript(c: string): boolean { } return wordBoundaryScriptRe.test(c); } -function needsSeparatorInAutoMode(a: string, b: string): boolean { +export function needsSeparatorInAutoMode(a: string, b: string): boolean { if (digitRe.test(a) && digitRe.test(b)) { return true; } @@ -82,6 +90,54 @@ function requiresSeparator( } } +// Convert a per-candidate (needsSep, spacingMode) pair into a +// SeparatorMode value. When needsSep is true (separator required), +// the grammar always uses spacePunctuation separators. +// When needsSep is false: "none" spacingMode → "none", otherwise +// → "optional" (covers auto mode/CJK/mixed and explicit "optional"). +function candidateSeparatorMode( + needsSep: boolean, + spacingMode: CompiledSpacingMode, +): SeparatorMode { + if (needsSep) { + return "spacePunctuation"; + } + if (spacingMode === "none") { + return "none"; + } + return "optional"; +} + +// Merge a new candidate's separator mode into the running aggregate. +// The mode requiring the strongest separator wins (i.e. the mode that +// demands the most from the user): space > spacePunctuation > optional > none. +function mergeSeparatorMode( + current: SeparatorMode | undefined, + needsSep: boolean, + spacingMode: CompiledSpacingMode, +): SeparatorMode { + const candidateMode = candidateSeparatorMode(needsSep, spacingMode); + if (current === undefined) { + return candidateMode; + } + // "space" requires strict whitespace — strongest requirement. + if (current === "space" || candidateMode === "space") { + return "space"; + } + // "spacePunctuation" requires a separator — next strongest. + if ( + current === "spacePunctuation" || + candidateMode === "spacePunctuation" + ) { + return "spacePunctuation"; + } + // "optional" is a stronger requirement than "none". + if (current === "optional" || candidateMode === "optional") { + return "optional"; + } + return "none"; +} + function isBoundarySatisfied( request: string, index: number, @@ -397,6 +453,13 @@ function createValue( } } +// Extract and trim a wildcard capture from `request[start..end)`. In the +// default spacing modes the result is stripped of leading/trailing separators +// (whitespace and punctuation). Returns `undefined` when the capture is empty +// or consists *entirely* of separator characters — e.g. a lone " " — so that +// the matcher rejects wildcard slots that contain no meaningful content. +// In "none" mode no trimming is performed; only a truly zero-length capture +// is rejected. function getWildcardStr( request: string, start: number, @@ -1026,6 +1089,29 @@ export type GrammarCompletionProperty = { export type GrammarCompletionResult = { completions: string[]; properties?: GrammarCompletionProperty[] | undefined; + // Number of characters from the input prefix that the grammar consumed + // before the completion point. The shell uses this to determine where + // to insert/filter completions (replacing the space-based heuristic). + matchedPrefixLength?: number | undefined; + // What kind of separator is expected between the content at + // `matchedPrefixLength` and the completion text. This is a + // *completion-result* concept (SeparatorMode), derived from the + // per-rule *match-time* spacing rules (CompiledSpacingMode / + // spacingMode) but distinct from them. + // "spacePunctuation" — whitespace or punctuation required + // (Latin "y" → "m" requires a separator). + // "optional" — separator accepted but not required + // (CJK 再生 → 音楽 does not require a separator). + // "none" — no separator at all ([spacing=none] grammars). + // Omitted when no completions were generated. + separatorMode?: SeparatorMode | undefined; + // True when `completions` is the closed set of valid + // continuations after the matched prefix — if the user types + // something not in the list, no further completions can exist + // beyond it. False or undefined means the parser can continue + // past unrecognized input and find more completions (e.g. + // wildcard/entity slots whose values are external to the grammar). + closedSet?: boolean | undefined; }; function getGrammarCompletionProperty( @@ -1033,6 +1119,8 @@ function getGrammarCompletionProperty( valueId: number, ): GrammarCompletionProperty | undefined { const temp = { ...state }; + + while (finalizeNestedRule(temp, undefined, true)) {} if (temp.valueIds === null) { // valueId would have been undefined throw new Error( @@ -1040,8 +1128,6 @@ function getGrammarCompletionProperty( ); } const wildcardPropertyNames: string[] = []; - - while (finalizeNestedRule(temp, undefined, true)) {} const match = createValue( temp.value, temp.valueIds, @@ -1085,99 +1171,384 @@ export function matchGrammar(grammar: Grammar, request: string) { } /** - * Check if the remaining input text is a case-insensitive prefix of a rule's - * string part. Used for completions when the user has partially typed a keyword. - * For example, prefix "p" should match and complete "play". + * Try to partially match leading words of a multi-word string part + * against the prefix starting at `startIndex`. Returns the consumed + * length and the remaining (unmatched) words as the completion text. + * + * - All words matched → returns undefined (caller should treat as + * a full match, not a completion candidate). + * - Some words matched → returns consumed length + next word. + * - No words matched → returns startIndex + first word. + * + * When returning a non-undefined result, it contains exactly one + * word as the completion text, providing one-word-at-a-time + * progression. */ -function isPartialPrefixOfStringPart( - prefix: string, - index: number, +function tryPartialStringMatch( part: StringPart, -): boolean { - // Get the remaining text after any leading separators - const remaining = prefix.slice(index).trimStart().toLowerCase(); - if (remaining.length === 0) { - return false; // No partial text - handled by the normal completion path + prefix: string, + startIndex: number, + spacingMode: CompiledSpacingMode, +): { consumedLength: number; remainingText: string } | undefined { + const words = part.value; + let index = startIndex; + let matchedWords = 0; + + for (const word of words) { + const escaped = escapeMatch(word); + const regExpStr = + spacingMode === "none" + ? escaped + : `[${separatorRegExpStr}]*?${escaped}`; + const re = new RegExp(regExpStr, "iuy"); + re.lastIndex = index; + const m = re.exec(prefix); + if (m === null) { + break; + } + const newIndex = m.index + m[0].length; + if (!isBoundarySatisfied(prefix, newIndex, spacingMode)) { + break; + } + index = newIndex; + matchedWords++; + } + + // No partial match found — either zero or all words matched + if (matchedWords >= words.length) { + return undefined; } - const partText = part.value.join(" ").toLowerCase(); - return partText.startsWith(remaining) && remaining.length < partText.length; + + return { + consumedLength: index, + remainingText: words[matchedWords], + }; } +/** + * Given a grammar and a user-typed prefix string, determine what completions + * are available. The algorithm greedily matches as many grammar parts as + * possible against the prefix (the "longest completable prefix"), then + * reports completions from the *next* unmatched part. + * + * The function explores every alternative rule/state in the grammar (via the + * `pending` work-list). Each state is run through `matchState` which + * consumes as many parts as the prefix allows. The state then falls into + * one of three categories: + * + * 1. **Exact match** — the prefix satisfies every part in the rule. + * No completion is needed, but `maxPrefixLength` is updated to + * the full input length so that completion candidates from shorter + * partial matches are eagerly discarded (via `updateMaxPrefixLength`). + * + * 2. **Partial match, finalized** — the prefix was consumed (possibly with + * trailing separators) but the rule still has remaining parts. + * `matchState` returns `false` (could not match the next part) and + * `finalizeState` returns `true` (no trailing non-separator junk). + * The next unmatched part produces a completion candidate: + * - String part → literal keyword completion (e.g. "music"). + * - Wildcard / number → property completion (handled elsewhere). + * + * 3. **Partial match, NOT finalized** — either: + * a. A pending wildcard could not be finalized (trailing text is only + * separators with no wildcard content) → emit a property completion + * for the wildcard's entity type. + * b. Trailing text remains that didn't match any part → + * attempt word-by-word matching of the current string part + * against that text (via `tryPartialStringMatch`). If some + * leading words match they advance the consumed prefix; the + * next unmatched word is emitted as a completion candidate. + * Candidates from shorter partial matches are automatically + * discarded when a longer match updates `maxPrefixLength`. + * + * During processing, whenever `maxPrefixLength` advances, all + * previously accumulated candidates are cleared. Only candidates + * whose prefix length equals the current maximum are kept. This + * ensures completions from shorter partial matches are discarded + * when a longer (or exact) match consumed more input. + * + * `matchedPrefixLength` tracks the furthest point consumed across all + * states — including exact matches (via `Math.max`). This tells the + * caller where the completable portion of the input ends, so it can + * position the completion insertion point correctly (especially important + * for non-space-separated scripts like CJK). + * + * `separatorMode` (a {@link SeparatorMode}) indicates what kind of + * separator is needed between the content at `matchedPrefixLength` and the + * completion text. It is determined by the spacing rules (the per-rule + * {@link CompiledSpacingMode}) between the last character of the matched + * prefix and the first character of the completion. + */ export function matchGrammarCompletion( grammar: Grammar, prefix: string, + minPrefixLength?: number, ): GrammarCompletionResult { debugCompletion(`Start completion for prefix: "${prefix}"`); + + // Seed the work-list with one MatchState per top-level grammar rule. + // matchState may push additional states (for nested rules, optional + // parts, wildcard extensions, repeat groups) during processing. const pending = initialMatchState(grammar); + + // Direct output arrays — candidates are added eagerly and cleared + // whenever maxPrefixLength increases, so no post-loop filtering is + // needed. Only candidates whose prefix length equals the current + // maximum are kept. const completions: string[] = []; const properties: GrammarCompletionProperty[] = []; + let separatorMode: SeparatorMode | undefined; + + // Whether the accumulated completions form a closed set — if the + // user types something not listed, no further completions can exist + // beyond it. Starts true and is set to false when property/wildcard + // completions are emitted (entity values are external to the grammar). + // Reset to true whenever maxPrefixLength advances (old candidates are + // discarded, new batch starts fresh). + let closedSet: boolean = true; + + // Track the furthest point the grammar consumed across all + // states (including exact matches). This tells the caller where + // the "filter text" begins so it doesn't have to guess from + // whitespace (which breaks for CJK and other non-space scripts). + let maxPrefixLength = minPrefixLength ?? 0; + + // Helper: update maxPrefixLength. When it increases, all previously + // accumulated completions from shorter matches are irrelevant + // — clear them. + function updateMaxPrefixLength(prefixLength: number): void { + if (prefixLength > maxPrefixLength) { + maxPrefixLength = prefixLength; + completions.length = 0; + properties.length = 0; + separatorMode = undefined; + closedSet = true; + } + } + + // --- Main loop: process every pending state --- while (pending.length > 0) { const state = pending.pop()!; debugMatch(state, `resume state`); + + // Attempt to greedily match as many grammar parts as possible + // against the prefix. `matched` is true only when ALL parts in + // the rule (including nested rules) were satisfied. matchState + // may also push new derivative states onto `pending` (e.g. for + // alternative nested rules, optional-skip paths, wildcard + // extensions, repeat iterations). const matched = matchState(state, prefix, pending); + // finalizeState does two things: + // 1. If a wildcard is pending at the end, attempt to capture + // all remaining input as its value. + // 2. Reject states that leave trailing non-separator characters + // un-consumed (those states don't represent valid parses). + // It returns true when the state is "clean" — all input was + // consumed (or only trailing separators remain). if (finalizeState(state, prefix)) { + // --- Category 1: Exact match --- + // All parts matched AND prefix was fully consumed. + // Nothing left to complete; but record how far we got + // so that completion candidates from shorter partial + // matches are eagerly discarded. if (matched) { debugCompletion("Matched. Nothing to complete."); - // Matched exactly, nothing to complete. + updateMaxPrefixLength(state.index); continue; } - // Completion with the current part + + // --- Category 2: Partial match (clean finalization) --- + // matchState stopped at state.partIndex because it couldn't + // match the next part against the (exhausted) prefix. + // That next part is what we offer as a completion. const nextPart = state.parts[state.partIndex]; debugCompletion(`Completing ${nextPart.type} part ${state.name}`); if (nextPart.type === "string") { - debugCompletion( - `Adding completion text: "${nextPart.value.join(" ")}"`, + // Use tryPartialStringMatch for one-word-at-a-time + // progression through string parts. + const partial = tryPartialStringMatch( + nextPart, + prefix, + state.index, + state.spacingMode, ); - completions.push(nextPart.value.join(" ")); + if (partial !== undefined) { + const candidatePrefixLength = partial.consumedLength; + const completionText = partial.remainingText; + updateMaxPrefixLength(candidatePrefixLength); + if (candidatePrefixLength === maxPrefixLength) { + debugCompletion( + `Adding completion text: "${completionText}" (consumed ${candidatePrefixLength} chars, spacing=${state.spacingMode ?? "auto"})`, + ); + + // Determine whether a separator (e.g. space) is needed + // between the content at matchedPrefixLength and the + // completion text. Check the boundary between the last + // consumed character and the first character of the + // completion. + let candidateNeedsSep = false; + if ( + candidatePrefixLength > 0 && + completionText.length > 0 && + state.spacingMode !== "none" + ) { + candidateNeedsSep = requiresSeparator( + prefix[candidatePrefixLength - 1], + completionText[0], + state.spacingMode, + ); + } + + completions.push(completionText); + separatorMode = mergeSeparatorMode( + separatorMode, + candidateNeedsSep, + state.spacingMode, + ); + } + } } + // Note: non-string next parts (wildcard, number, rules) in + // Category 2 don't produce completions here — wildcards are + // handled by Category 3a (pending wildcard) and nested rules + // are expanded by matchState into separate pending states. } else { - // We can't finalize the state because of empty pending wildcard - // or because there's trailing unmatched text. + // --- Category 3: finalizeState failed --- + // Either (a) a pending wildcard couldn't capture meaningful + // content, or (b) trailing non-separator text remains that + // didn't match any grammar part. const pendingWildcard = state.pendingWildcard; + if ( pendingWildcard !== undefined && pendingWildcard.valueId !== undefined ) { + // --- Category 3a: Unfinalizable pending wildcard --- + // The grammar reached a wildcard slot but its capture + // region is empty or separator-only (e.g. prefix="play " + // with wildcard starting at index 4 — the space is not + // valid wildcard content). Instead of offering the + // *following* string part as a completion, we report a + // property completion describing the wildcard's type so + // the caller can provide entity-specific suggestions. debugCompletion("Completing wildcard part"); const completionProperty = getGrammarCompletionProperty( state, pendingWildcard.valueId, ); if (completionProperty !== undefined) { - debugCompletion( - `Adding completion property: ${JSON.stringify(completionProperty)}`, - ); - properties.push(completionProperty); + const candidatePrefixLength = pendingWildcard.start; + updateMaxPrefixLength(candidatePrefixLength); + if (candidatePrefixLength === maxPrefixLength) { + debugCompletion( + `Adding completion property: ${JSON.stringify(completionProperty)}`, + ); + // Determine whether a separator is needed between + // the content at matchedPrefixLength and the + // completion (the wildcard entity value). Check + // the boundary between the last consumed character + // before the wildcard and the first character of the + // entity value. We use "a" as a representative word + // character since the actual value is unknown. + let candidateNeedsSep = false; + if ( + pendingWildcard.start > 0 && + state.spacingMode !== "none" + ) { + candidateNeedsSep = requiresSeparator( + prefix[pendingWildcard.start - 1], + "a", + state.spacingMode, + ); + } + + properties.push(completionProperty); + separatorMode = mergeSeparatorMode( + separatorMode, + candidateNeedsSep, + state.spacingMode, + ); + // Property/wildcard completions are not a closed + // set — entity values are external to the grammar. + closedSet = false; + } } } else if (!matched) { - // matchState failed on a string part and there's trailing text. - // Check if the remaining input is a partial prefix of the - // current string part (e.g. "p" is a prefix of "play"). + // --- Category 3b: Completion after consumed prefix --- + // The grammar stopped at a string part it could not + // match. Report the string part as a completion + // candidate regardless of any trailing text — the + // caller can use matchedPrefixLength to determine how + // much of the input was successfully consumed and + // filter completions by any trailing text beyond that + // point. Candidates from shorter partial matches are + // automatically discarded when a longer match updates + // maxPrefixLength. const currentPart = state.parts[state.partIndex]; if ( currentPart !== undefined && - currentPart.type === "string" && - isPartialPrefixOfStringPart( + currentPart.type === "string" + ) { + // For multi-word string parts (e.g. ["play", "shuffle"]), + // the all-at-once regex may have failed even though some + // leading words DO match the prefix. Try word-by-word + // to recover the partial match and offer only the next + // unmatched word as the completion (one word at a time). + const partial = tryPartialStringMatch( + currentPart, prefix, state.index, - currentPart, - ) - ) { - const fullText = currentPart.value.join(" "); - debugCompletion( - `Adding partial prefix completion: "${fullText}"`, + state.spacingMode, ); - completions.push(fullText); + if (partial === undefined) { + continue; + } + const candidatePrefixLength = partial.consumedLength; + const completionText = partial.remainingText; + + updateMaxPrefixLength(candidatePrefixLength); + if (candidatePrefixLength === maxPrefixLength) { + debugCompletion( + `Adding completion: "${completionText}" (consumed ${candidatePrefixLength} chars)`, + ); + + // Determine whether a separator is needed between + // the matched prefix and the completion text. Check + // the boundary between the last consumed character + // and the first character of the completion. + let candidateNeedsSep = false; + if ( + candidatePrefixLength > 0 && + completionText.length > 0 && + state.spacingMode !== "none" + ) { + candidateNeedsSep = requiresSeparator( + prefix[candidatePrefixLength - 1], + completionText[0], + state.spacingMode, + ); + } + + completions.push(completionText); + separatorMode = mergeSeparatorMode( + separatorMode, + candidateNeedsSep, + state.spacingMode, + ); + } } } } } - const result = { + const result: GrammarCompletionResult = { completions, properties, + matchedPrefixLength: maxPrefixLength, + separatorMode, + closedSet, }; debugCompletion(`Completed. ${JSON.stringify(result)}`); return result; diff --git a/ts/packages/actionGrammar/src/index.ts b/ts/packages/actionGrammar/src/index.ts index 6ca548c44..0ab577db1 100644 --- a/ts/packages/actionGrammar/src/index.ts +++ b/ts/packages/actionGrammar/src/index.ts @@ -26,6 +26,7 @@ export { GrammarMatchResult, matchGrammarCompletion, GrammarCompletionResult, + needsSeparatorInAutoMode, } from "./grammarMatcher.js"; // Entity system diff --git a/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts new file mode 100644 index 000000000..0391e9c9f --- /dev/null +++ b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts @@ -0,0 +1,291 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { loadGrammarRules } from "../src/grammarLoader.js"; +import { matchGrammarCompletion } from "../src/grammarMatcher.js"; + +describe("Grammar Completion - all alternatives after longest match", () => { + // After the longest fully matched prefix, ALL valid next-part + // completions are reported regardless of trailing partial text. + // The caller is responsible for filtering by the trailing text. + + describe("alternatives preserved with partial trailing text", () => { + const g = [ + ` = $(a:) $(b:) $(c:) -> { a, b, c };`, + ` = play -> "play";`, + ` = rock -> "rock";`, + ` = music -> "music";`, + ` = hard -> "hard";`, + ` = loud -> "loud";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("without partial text: all alternatives are offered", () => { + const result = matchGrammarCompletion(grammar, "play rock"); + expect(result.completions.sort()).toEqual([ + "hard", + "loud", + "music", + ]); + expect(result.matchedPrefixLength).toBe(9); + }); + + it("with trailing space: all alternatives are still offered", () => { + const result = matchGrammarCompletion(grammar, "play rock "); + expect(result.completions.sort()).toEqual([ + "hard", + "loud", + "music", + ]); + }); + + it("partial text 'm': all alternatives still reported", () => { + const result = matchGrammarCompletion(grammar, "play rock m"); + // All three are reported; caller filters by "m". + expect(result.completions.sort()).toEqual([ + "hard", + "loud", + "music", + ]); + expect(result.matchedPrefixLength).toBe(9); + }); + + it("partial text 'h': all alternatives still reported", () => { + const result = matchGrammarCompletion(grammar, "play rock h"); + expect(result.completions.sort()).toEqual([ + "hard", + "loud", + "music", + ]); + expect(result.matchedPrefixLength).toBe(9); + }); + + it("non-matching text 'x': all alternatives still reported", () => { + const result = matchGrammarCompletion(grammar, "play rock x"); + // All alternatives are reported even though "x" doesn't + // prefix-match any of them; the caller filters. + expect(result.completions.sort()).toEqual([ + "hard", + "loud", + "music", + ]); + expect(result.matchedPrefixLength).toBe(9); + }); + }); + + describe("inline single-part alternatives preserved", () => { + const g = [` = go (north | south | east | west) -> true;`].join( + "\n", + ); + const grammar = loadGrammarRules("test.grammar", g); + + it("all directions offered without partial text", () => { + const result = matchGrammarCompletion(grammar, "go"); + expect(result.completions.sort()).toEqual([ + "east", + "north", + "south", + "west", + ]); + }); + + it("'n' trailing: all directions still offered", () => { + const result = matchGrammarCompletion(grammar, "go n"); + expect(result.completions.sort()).toEqual([ + "east", + "north", + "south", + "west", + ]); + expect(result.matchedPrefixLength).toBe(2); + }); + + it("'z' trailing: all directions still offered", () => { + const result = matchGrammarCompletion(grammar, "go z"); + expect(result.completions.sort()).toEqual([ + "east", + "north", + "south", + "west", + ]); + expect(result.matchedPrefixLength).toBe(2); + }); + }); + + describe("all alternatives after consumed prefix", () => { + const g = [ + ` = $(a:) $(b:) -> { a, b };`, + ` = open -> "open";`, + ` = file -> "file";`, + ` = folder -> "folder";`, + ` = finder -> "finder";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("'open f': all alternatives offered", () => { + const result = matchGrammarCompletion(grammar, "open f"); + expect(result.completions.sort()).toEqual([ + "file", + "finder", + "folder", + ]); + }); + + it("'open fi': all alternatives still offered", () => { + const result = matchGrammarCompletion(grammar, "open fi"); + // "folder" is now correctly reported alongside file/finder. + expect(result.completions.sort()).toEqual([ + "file", + "finder", + "folder", + ]); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("no false completions when nothing consumed", () => { + // When state.index === 0 (no prefix consumed), we should NOT + // report unrelated string parts — only partial prefix matches. + const g = [ + ` = play $(g:) -> { genre: g };`, + ` = rock -> "rock";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("all first parts offered for unrelated input", () => { + const result = matchGrammarCompletion(grammar, "xyz"); + // Nothing consumed; the first string part is offered + // unconditionally. The caller filters by trailing text. + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("partial prefix at start still works", () => { + const result = matchGrammarCompletion(grammar, "pl"); + expect(result.completions).toContain("play"); + }); + }); + + describe("separatorMode in Category 3b", () => { + // Category 3b: finalizeState failed, trailing non-separator text + // remains. separatorMode indicates whether a separator is needed + // between matchedPrefixLength and the completion text. + + describe("Latin grammar (auto spacing)", () => { + const g = [ + ` = $(a:) $(b:) -> { a, b };`, + ` = play -> "a";`, + ` = music -> "b";`, + ` = midi -> "b2";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports separatorMode after consumed Latin prefix with trailing text", () => { + // "play x" → consumed "play" (4 chars), trailing "x" fails. + // Completion "music"/"midi" at matchedPrefixLength=4. + // Last consumed char: "y" (Latin), first completion char: "m" (Latin) + // → separator needed. + const result = matchGrammarCompletion(grammar, "play x"); + expect(result.completions.sort()).toEqual(["midi", "music"]); + expect(result.matchedPrefixLength).toBe(4); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("reports separatorMode with partial-match trailing text", () => { + // "play mu" → consumed "play" (4 chars), trailing "mu". + // Same boundary: "y" → "m" → separator needed. + const result = matchGrammarCompletion(grammar, "play mu"); + expect(result.completions.sort()).toEqual(["midi", "music"]); + expect(result.matchedPrefixLength).toBe(4); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + }); + + describe("CJK grammar (auto spacing)", () => { + const g = [ + ` [spacing=auto] = $(a:) $(b:) -> { a, b };`, + ` [spacing=auto] = 再生 -> "a";`, + ` [spacing=auto] = 音楽 -> "b";`, + ` [spacing=auto] = 映画 -> "b2";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports optional separatorMode for CJK → CJK", () => { + // "再生x" → consumed "再生" (2 chars), trailing "x" fails. + // Last consumed char: "生" (CJK), first completion: "音" (CJK) + // → separator optional in auto mode. + const result = matchGrammarCompletion(grammar, "再生x"); + expect(result.completions.sort()).toEqual(["映画", "音楽"]); + expect(result.matchedPrefixLength).toBe(2); + expect(result.separatorMode).toBe("optional"); + }); + }); + + describe("nothing consumed (index=0)", () => { + const g = [ + ` = play $(g:) -> { genre: g };`, + ` = rock -> "rock";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports optional separatorMode when nothing consumed", () => { + // "xyz" → consumed 0 chars, offers "play" at prefixLength=0. + // No last consumed char → no separator check. + const result = matchGrammarCompletion(grammar, "xyz"); + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + expect(result.separatorMode).toBe("optional"); + }); + }); + + describe("spacing=required", () => { + const g = [ + ` [spacing=required] = $(a:) $(b:) -> { a, b };`, + ` [spacing=required] = play -> "a";`, + ` [spacing=required] = music -> "b";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports separatorMode for spacing=required with trailing text", () => { + const result = matchGrammarCompletion(grammar, "play x"); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + }); + + describe("spacing=optional", () => { + const g = [ + ` [spacing=optional] = $(a:) $(b:) -> { a, b };`, + ` [spacing=optional] = play -> "a";`, + ` [spacing=optional] = music -> "b";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports optional separatorMode for spacing=optional", () => { + const result = matchGrammarCompletion(grammar, "play x"); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + expect(result.separatorMode).toBe("optional"); + }); + }); + + describe("mixed scripts (Latin → CJK)", () => { + const g = [ + ` [spacing=auto] = $(a:) $(b:) -> { a, b };`, + ` [spacing=auto] = play -> "a";`, + ` [spacing=auto] = 音楽 -> "b";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports optional separatorMode for Latin → CJK", () => { + // Last consumed: "y" (Latin), completion: "音" (CJK) + // → different scripts, separator optional in auto mode. + const result = matchGrammarCompletion(grammar, "play x"); + expect(result.completions).toEqual(["音楽"]); + expect(result.matchedPrefixLength).toBe(4); + expect(result.separatorMode).toBe("optional"); + }); + }); + }); +}); diff --git a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts new file mode 100644 index 000000000..195f419d1 --- /dev/null +++ b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts @@ -0,0 +1,669 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { loadGrammarRules } from "../src/grammarLoader.js"; +import { matchGrammarCompletion } from "../src/grammarMatcher.js"; + +describe("Grammar Completion - longest match property", () => { + describe("three sequential parts", () => { + // Verifies that after matching 2 of 3 parts, completion is the 3rd part. + const g = [ + ` = $(a:) $(b:) $(c:) -> { a, b, c };`, + ` = first -> "a";`, + ` = second -> "b";`, + ` = third -> "c";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("completes first part for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toContain("first"); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("completes second part after first matched", () => { + const result = matchGrammarCompletion(grammar, "first"); + expect(result.completions).toContain("second"); + expect(result.matchedPrefixLength).toBe(5); + }); + + it("completes second part after first matched with space", () => { + const result = matchGrammarCompletion(grammar, "first "); + expect(result.completions).toContain("second"); + expect(result.matchedPrefixLength).toBe(5); + }); + + it("completes third part after first two matched", () => { + const result = matchGrammarCompletion(grammar, "first second"); + expect(result.completions).toContain("third"); + expect(result.matchedPrefixLength).toBe(12); + }); + + it("completes third part after first two matched with space", () => { + const result = matchGrammarCompletion(grammar, "first second "); + expect(result.completions).toContain("third"); + expect(result.matchedPrefixLength).toBe(12); + }); + + it("no completion for exact full match", () => { + const result = matchGrammarCompletion( + grammar, + "first second third", + ); + expect(result.completions).toHaveLength(0); + // Exact match records the full consumed length. + expect(result.matchedPrefixLength).toBe(18); + }); + + it("partial prefix of third part completes correctly", () => { + const result = matchGrammarCompletion(grammar, "first second th"); + expect(result.completions).toContain("third"); + expect(result.matchedPrefixLength).toBe(12); + }); + }); + + describe("four sequential parts", () => { + const g = [ + ` = $(a:) $(b:) $(c:) $(d:) -> { a, b, c, d };`, + ` = alpha -> "a";`, + ` = bravo -> "b";`, + ` = charlie -> "c";`, + ` = delta -> "d";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("completes delta after three parts matched", () => { + const result = matchGrammarCompletion( + grammar, + "alpha bravo charlie", + ); + expect(result.completions).toContain("delta"); + expect(result.matchedPrefixLength).toBe(19); + }); + + it("completes charlie after two parts matched", () => { + const result = matchGrammarCompletion(grammar, "alpha bravo"); + expect(result.completions).toContain("charlie"); + expect(result.matchedPrefixLength).toBe(11); + }); + }); + + describe("competing rules - longer match wins", () => { + // Two rules: short (2 parts) and long (3 parts). + // When first two parts match, the short rule is a full match (no + // completion) and the long rule offers completion for the third part. + const g = [ + ` = $(a:) $(b:) -> { a, b };`, + ` = $(a:) $(b:) $(c:) -> { a, b, c };`, + ` = alpha -> "a";`, + ` = bravo -> "b";`, + ` = charlie -> "c";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("short rule is exact match; long rule offers completion", () => { + const result = matchGrammarCompletion(grammar, "alpha bravo"); + // Short rule matches exactly, no completion from it. + // Long rule matches alpha + bravo and offers "charlie". + expect(result.completions).toContain("charlie"); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("both rules offer completions at same depth for first part", () => { + const result = matchGrammarCompletion(grammar, "alpha"); + // Both rules need "bravo" next. + expect(result.completions).toContain("bravo"); + expect(result.matchedPrefixLength).toBe(5); + }); + }); + + describe("competing rules - different next parts at same depth", () => { + // Two rules that share a prefix but diverge after. + const g = [ + ` = $(a:) suffix_x -> "rx";`, + ` = $(a:) suffix_y -> "ry";`, + ` = prefix -> "a";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("offers both alternatives after shared prefix", () => { + const result = matchGrammarCompletion(grammar, "prefix"); + expect(result.completions).toContain("suffix_x"); + expect(result.completions).toContain("suffix_y"); + expect(result.matchedPrefixLength).toBe(6); + }); + + it("alternative still offered even when one rule matches exactly", () => { + const result = matchGrammarCompletion(grammar, "prefix suffix_x"); + // Rule 1 (suffix_x) matches exactly at length 15. + // Rule 2's "suffix_y" at prefixLength 6 is filtered out + // because a longer match exists. + expect(result.completions).toHaveLength(0); + expect(result.matchedPrefixLength).toBe(15); + }); + }); + + describe("optional part followed by required part", () => { + const g = [ + ` = $(a:) $(b:)? $(c:) -> { a, c };`, + ` = begin -> "a";`, + ` = middle -> "b";`, + ` = finish -> "c";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("offers both optional and skip alternatives after first part", () => { + const result = matchGrammarCompletion(grammar, "begin"); + // Should offer "middle" (optional) and "finish" (skipping optional) + expect(result.completions).toContain("middle"); + expect(result.completions).toContain("finish"); + expect(result.matchedPrefixLength).toBe(5); + }); + + it("offers finish after optional part matched", () => { + const result = matchGrammarCompletion(grammar, "begin middle"); + expect(result.completions).toContain("finish"); + expect(result.matchedPrefixLength).toBe(12); + }); + + it("longest path wins for 'finish' completion when optional is matched", () => { + // When "begin middle" is typed: + // - Through-optional path: matches "begin" + "middle", + // offers "finish" at matchedPrefixLength=12. + // - Skip-optional path: matches "begin" (index=5), + // offers "finish" at matchedPrefixLength=5 — but this + // is filtered out because a longer match (12) exists. + const result = matchGrammarCompletion(grammar, "begin middle"); + expect(result.completions).toContain("finish"); + expect(result.matchedPrefixLength).toBe(12); + }); + }); + + describe("completion after number variable", () => { + const g = [ + ` = set volume $(n:number) percent -> { volume: n };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("completes 'percent' after number matched", () => { + const result = matchGrammarCompletion(grammar, "set volume 50"); + expect(result.completions).toContain("percent"); + expect(result.matchedPrefixLength).toBe(13); + }); + + it("completes 'percent' after number with space", () => { + const result = matchGrammarCompletion(grammar, "set volume 50 "); + expect(result.completions).toContain("percent"); + expect(result.matchedPrefixLength).toBe(13); + }); + + it("no completion for exact match", () => { + const result = matchGrammarCompletion( + grammar, + "set volume 50 percent", + ); + expect(result.completions).toHaveLength(0); + }); + }); + + describe("multiple alternatives in nested rule", () => { + // After matching a prefix, the nested rule has multiple alternatives + // for the next part, all at the same depth. + const g = [ + ` = play $(g:) -> { genre: g };`, + ` = rock -> "rock";`, + ` = pop -> "pop";`, + ` = jazz -> "jazz";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("offers all genre alternatives after 'play'", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toContain("rock"); + expect(result.completions).toContain("pop"); + expect(result.completions).toContain("jazz"); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("all alternatives offered even with partial trailing text", () => { + const result = matchGrammarCompletion(grammar, "play r"); + // All genre alternatives are reported after the longest + // complete match "play"; the caller filters by "r". + expect(result.completions).toContain("rock"); + expect(result.completions).toContain("pop"); + expect(result.completions).toContain("jazz"); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("all alternatives offered with 'p' trailing text", () => { + const result = matchGrammarCompletion(grammar, "play p"); + // All are reported; caller filters by "p". + expect(result.completions).toContain("pop"); + expect(result.completions).toContain("rock"); + expect(result.completions).toContain("jazz"); + }); + }); + + describe("wildcard between string parts - longest match", () => { + // Grammar: verb WILDCARD terminator + // Completion should offer terminator only after wildcard captures text. + const g = [ + ` = play $(name) by $(artist) -> { name, artist };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("offers wildcard property after 'play'", () => { + const result = matchGrammarCompletion(grammar, "play"); + // Wildcard is next, should have property completion + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + }); + + it("offers 'by' terminator after wildcard text", () => { + const result = matchGrammarCompletion(grammar, "play hello"); + expect(result.completions).toContain("by"); + }); + + it("offers artist wildcard property after 'by'", () => { + const result = matchGrammarCompletion(grammar, "play hello by"); + // After "by", the next part is the artist wildcard + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + }); + }); + + describe("deep nesting - three levels of rules", () => { + const g = [ + ` = $(x:) done -> { x };`, + ` = $(y:) -> y;`, + ` = deep -> "deep";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("completes 'deep' for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toContain("deep"); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("completes 'done' after deeply nested match", () => { + const result = matchGrammarCompletion(grammar, "deep"); + expect(result.completions).toContain("done"); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("repeat group (+) completion", () => { + // Grammar with ()+ repeat group using inline alternatives + const g = ` = hello (world | earth)+ done -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("offers 'hello' for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toContain("hello"); + }); + + it("offers repeat alternatives after 'hello'", () => { + const result = matchGrammarCompletion(grammar, "hello"); + // After "hello", the ()+ group requires at least one match + expect(result.completions).toContain("world"); + expect(result.completions).toContain("earth"); + }); + + it("offers 'done' and repeat alternatives after first repeat match", () => { + const result = matchGrammarCompletion(grammar, "hello world"); + // After one repeat match, can repeat or proceed to "done" + expect(result.completions).toContain("done"); + // Also should offer repeat alternatives + expect( + result.completions.includes("world") || + result.completions.includes("earth"), + ).toBe(true); + }); + + it("offers 'done' after two repeat matches", () => { + const result = matchGrammarCompletion(grammar, "hello world earth"); + expect(result.completions).toContain("done"); + }); + }); + + describe("partial prefix vs longest match interaction", () => { + // Test that partial prefix matching from a shorter rule does not + // interfere with the longest match from a longer rule. + // + // Rule 1's string part "beta gamma" partially matches "beta" in the + // remaining text, while Rule 2 fully matches "beta" as a separate + // nested rule and offers "gamma" from the longer match. + // + // Both completions may appear because they're valid for different + // interpretations. The key property is that matchedPrefixLength + // reflects the longest consumed prefix. + const g = [ + ` = $(a:) beta gamma -> "r1";`, + ` = $(a:) $(b:) gamma -> "r2";`, + ` = alpha -> "a";`, + ` = beta -> "b";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("matchedPrefixLength reflects longest consumed prefix", () => { + const result = matchGrammarCompletion(grammar, "alpha beta"); + // Rule 2 matches alpha + beta = 10 chars. + // matchedPrefixLength should be at least 10 (from the longest match). + expect(result.matchedPrefixLength).toBeGreaterThanOrEqual(10); + // "gamma" must appear as completion from the longer match. + expect(result.completions).toContain("gamma"); + }); + + it("shorter partial prefix completion also appears (known behavior)", () => { + const result = matchGrammarCompletion(grammar, "alpha beta"); + // Rule 1 may produce "beta gamma" via isPartialPrefixOfStringPart + // since "beta" is a prefix of "beta gamma". This is expected: + // both rules produce valid completions for the input. + // We verify maxPrefixLength is from the longest match. + expect(result.matchedPrefixLength).toBe(10); + }); + }); + + describe("case insensitive matching for completions", () => { + const g = [ + ` = $(a:) World -> { a };`, + ` = Hello -> "hello";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("completes after case-insensitive match", () => { + const result = matchGrammarCompletion(grammar, "hello"); + expect(result.completions).toContain("World"); + expect(result.matchedPrefixLength).toBe(5); + }); + + it("completes after uppercase input", () => { + const result = matchGrammarCompletion(grammar, "HELLO"); + expect(result.completions).toContain("World"); + expect(result.matchedPrefixLength).toBe(5); + }); + + it("partial prefix is case insensitive", () => { + const result = matchGrammarCompletion(grammar, "hello WO"); + expect(result.completions).toContain("World"); + }); + }); + + describe("no spurious completions from unrelated rules", () => { + // Two completely different rules. Only the matching one should + // produce completions. + const g = [ + ` = play $(g:) -> { action: "play", genre: g };`, + ` = stop $(r:) -> { action: "stop", reason: r };`, + ` = rock -> "rock";`, + ` = now -> "now";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("only matching rule offers completions for 'play'", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toContain("rock"); + expect(result.completions).not.toContain("now"); + expect(result.completions).not.toContain("stop"); + }); + + it("only matching rule offers completions for 'stop'", () => { + const result = matchGrammarCompletion(grammar, "stop"); + expect(result.completions).toContain("now"); + expect(result.completions).not.toContain("rock"); + expect(result.completions).not.toContain("play"); + }); + + it("all first parts offered for unrelated input", () => { + const result = matchGrammarCompletion(grammar, "dance"); + // Nothing consumed; all first string parts from every rule + // are offered at prefixLength 0. The caller filters by + // the trailing text "dance". + expect(result.completions.sort()).toEqual(["play", "stop"]); + expect(result.matchedPrefixLength).toBe(0); + }); + }); + + describe("completion with entity wildcard", () => { + // Entity wildcards should produce property completions, not string + // completions, and matchedPrefixLength should indicate where the + // entity value begins. + const g = [ + `entity SongName;`, + ` = play $(song:SongName) next -> { song };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("entity wildcard produces property completion", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("string terminator after entity text", () => { + const result = matchGrammarCompletion(grammar, "play mysong"); + expect(result.completions).toContain("next"); + }); + }); + + describe("completion at boundary between consumed and remaining", () => { + // Verify that trailing separators (spaces) don't affect + // which part is offered for completion. + const g = [ + ` = $(a:) $(b:) -> { a, b };`, + ` = one -> "a";`, + ` = two -> "b";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("same completion with and without trailing space", () => { + const r1 = matchGrammarCompletion(grammar, "one"); + const r2 = matchGrammarCompletion(grammar, "one "); + const r3 = matchGrammarCompletion(grammar, "one "); + expect(r1.completions).toEqual(r2.completions); + expect(r2.completions).toEqual(r3.completions); + expect(r1.completions).toContain("two"); + }); + + it("matchedPrefixLength is stable regardless of trailing spaces", () => { + const r1 = matchGrammarCompletion(grammar, "one"); + const r2 = matchGrammarCompletion(grammar, "one "); + const r3 = matchGrammarCompletion(grammar, "one "); + // All should report the same matchedPrefixLength (end of + // consumed prefix, which is the "one" portion) + expect(r1.matchedPrefixLength).toBe(r2.matchedPrefixLength); + expect(r2.matchedPrefixLength).toBe(r3.matchedPrefixLength); + }); + }); + + describe("closedSet flag - exhaustiveness", () => { + describe("string-only completions are exhaustive", () => { + const g = [ + ` = play $(g:) -> { genre: g };`, + ` = rock -> "rock";`, + ` = pop -> "pop";`, + ` = jazz -> "jazz";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("closedSet=true for first string part on empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toContain("play"); + expect(result.closedSet).toBe(true); + }); + + it("closedSet=true for alternatives after prefix", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toContain("rock"); + expect(result.completions).toContain("pop"); + expect(result.completions).toContain("jazz"); + expect(result.closedSet).toBe(true); + }); + + it("closedSet=true for exact match (no completions)", () => { + const result = matchGrammarCompletion(grammar, "play rock"); + expect(result.completions).toHaveLength(0); + expect(result.closedSet).toBe(true); + }); + }); + + describe("wildcard/entity completions are not exhaustive", () => { + const g = [ + `entity SongName;`, + ` = play $(song:SongName) next -> { song };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("closedSet=false for entity wildcard property completion", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + expect(result.closedSet).toBe(false); + }); + + it("closedSet=true for string completion after wildcard", () => { + const result = matchGrammarCompletion(grammar, "play mysong"); + expect(result.completions).toContain("next"); + // The "next" keyword is the only valid continuation + // from the grammar — no property completions at this + // point — so completions are exhaustive. + expect(result.closedSet).toBe(true); + }); + }); + + describe("untyped wildcard produces property completion → not exhaustive", () => { + const g = [ + ` = play $(name) by $(artist) -> { name, artist };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("closedSet=false for untyped wildcard property", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + expect(result.closedSet).toBe(false); + }); + + it("closedSet=true for 'by' keyword after wildcard captured", () => { + const result = matchGrammarCompletion(grammar, "play hello"); + expect(result.completions).toContain("by"); + expect(result.closedSet).toBe(true); + }); + }); + + describe("mixed string and entity at same prefix length", () => { + // Two rules sharing "play" prefix: one leads to string + // completion ("shuffle"), one to entity property. + // Both completions should be present regardless of rule order. + + it("entity rule first, string rule second", () => { + const g = [ + `entity SongName;`, + ` = play $(song:SongName) -> { action: "search", song };`, + ` = play shuffle -> { action: "shuffle" };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + const result = matchGrammarCompletion(grammar, "play"); + expect(result.matchedPrefixLength).toBe(4); + expect(result.completions).toContain("shuffle"); + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + expect(result.closedSet).toBe(false); + }); + + it("string rule first, entity rule second", () => { + const g = [ + `entity SongName;`, + ` = play shuffle -> { action: "shuffle" };`, + ` = play $(song:SongName) -> { action: "search", song };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + const result = matchGrammarCompletion(grammar, "play"); + expect(result.matchedPrefixLength).toBe(4); + expect(result.completions).toContain("shuffle"); + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + expect(result.closedSet).toBe(false); + }); + }); + + describe("competing rules - longer match resets closedSet", () => { + const g = [ + `entity SongName;`, + ` = $(a:) $(song:SongName) -> { a, song };`, + ` = $(a:) $(b:) finish -> { a, b };`, + ` = alpha -> "a";`, + ` = bravo -> "b";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("closedSet reflects longest prefix length result", () => { + const result = matchGrammarCompletion(grammar, "alpha bravo"); + // Rule 2 matches alpha+bravo (11 chars) and offers "finish" + // (string → closedSet=true). + // Rule 1 matches alpha (5 chars) and offers entity + // (closedSet=false) — but this is at a shorter prefix + // length so it's discarded. + expect(result.completions).toContain("finish"); + expect(result.matchedPrefixLength).toBe(11); + expect(result.closedSet).toBe(true); + }); + }); + + describe("sequential parts - closedSet stays true throughout", () => { + const g = [ + ` = $(a:) $(b:) $(c:) -> { a, b, c };`, + ` = first -> "a";`, + ` = second -> "b";`, + ` = third -> "c";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("closedSet=true for first part", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toContain("first"); + expect(result.closedSet).toBe(true); + }); + + it("closedSet=true for second part", () => { + const result = matchGrammarCompletion(grammar, "first"); + expect(result.completions).toContain("second"); + expect(result.closedSet).toBe(true); + }); + + it("closedSet=true for third part", () => { + const result = matchGrammarCompletion(grammar, "first second"); + expect(result.completions).toContain("third"); + expect(result.closedSet).toBe(true); + }); + + it("closedSet=true for exact full match", () => { + const result = matchGrammarCompletion( + grammar, + "first second third", + ); + expect(result.completions).toHaveLength(0); + expect(result.closedSet).toBe(true); + }); + }); + + describe("optional parts", () => { + const g = [ + ` = $(a:) $(b:)? $(c:) -> { a, c };`, + ` = begin -> "a";`, + ` = middle -> "b";`, + ` = finish -> "c";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("closedSet=true for alternatives after optional", () => { + const result = matchGrammarCompletion(grammar, "begin"); + expect(result.completions).toContain("middle"); + expect(result.completions).toContain("finish"); + expect(result.closedSet).toBe(true); + }); + }); + }); +}); diff --git a/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts new file mode 100644 index 000000000..c71d5216a --- /dev/null +++ b/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { loadGrammarRules } from "../src/grammarLoader.js"; +import { matchGrammarCompletion } from "../src/grammarMatcher.js"; + +describe("Grammar Completion - nested wildcard through rules", () => { + // Reproduces the bug where completing "play" returns "by" instead of + // a completionProperty for the wildcard . + // + // Grammar: + // = play $(trackName:) by $(artist:) + // -> { actionName: "playTrack", parameters: { trackName, artists: [artist] } } + // = $(trackName:) -> trackName + // = $(x:wildcard) + // = $(x:wildcard) + const g = [ + ` = play $(trackName:) by $(artist:) -> { actionName: "playTrack", parameters: { trackName, artists: [artist] } };`, + ` = $(trackName:) -> trackName;`, + ` = $(x:wildcard);`, + ` = $(x:wildcard);`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it('should return completionProperty for wildcard after "play"', () => { + const result = matchGrammarCompletion(grammar, "play"); + // After matching "play", the next part is $(trackName:) + // which ultimately resolves to a wildcard. The completion should + // include a property for that wildcard, not just "by". + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + }); + + it('should return completionProperty for wildcard after "play "', () => { + const result = matchGrammarCompletion(grammar, "play "); + // Same as above but with trailing space + expect(result.properties).toBeDefined(); + expect(result.properties!.length).toBeGreaterThan(0); + }); + + it('should return "by" as completion after wildcard text', () => { + const result = matchGrammarCompletion(grammar, "play some song"); + // After the wildcard has captured text, "by" should appear as a + // completion for the next string part. + expect(result.completions).toContain("by"); + }); +}); diff --git a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts new file mode 100644 index 000000000..615d22597 --- /dev/null +++ b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts @@ -0,0 +1,357 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { loadGrammarRules } from "../src/grammarLoader.js"; +import { matchGrammarCompletion } from "../src/grammarMatcher.js"; + +describe("Grammar Completion - matchedPrefixLength", () => { + describe("single string part", () => { + // All words in one string part — when no leading words match, + // matchedPrefixLength is 0 and only the first word is offered + // as a completion. When leading words match, matchedPrefixLength + // advances and only the remaining words are offered. + const g = ` = play music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("returns first word as completion for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("returns first word as completion for partial prefix", () => { + const result = matchGrammarCompletion(grammar, "pl"); + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("returns remaining words as completion for first word typed", () => { + const result = matchGrammarCompletion(grammar, "play "); + // tryPartialStringMatch splits the multi-word part: "play" + // is consumed (4 chars), "music" remains as the completion. + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("returns first word for non-matching input", () => { + const result = matchGrammarCompletion(grammar, "xyz"); + // Nothing consumed; only the first word of the string part is + // offered so the caller can filter by trailing text. + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("returns matchedPrefixLength for exact match", () => { + const result = matchGrammarCompletion(grammar, "play music"); + expect(result.completions).toHaveLength(0); + // Exact match now records the full consumed length. + expect(result.matchedPrefixLength).toBe(10); + }); + }); + + describe("multi-part via nested rule", () => { + // Nested rule creates separate parts, so matchedPrefixLength + // reflects the position after the consumed nested rule. + const g = [ + ` = $(v:) music -> true;`, + ` = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("returns nested rule text for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("returns second part after nested rule consumed", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("returns second part after nested rule with trailing space", () => { + const result = matchGrammarCompletion(grammar, "play "); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("returns second part for partial second word", () => { + const result = matchGrammarCompletion(grammar, "play m"); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("returns matchedPrefixLength for complete match", () => { + const result = matchGrammarCompletion(grammar, "play music"); + expect(result.completions).toHaveLength(0); + expect(result.matchedPrefixLength).toBe(10); + }); + }); + + describe("multiple rules with shared prefix", () => { + // Multiple rules that share a prefix via nested rules + const g = [ + ` = $(v:) music -> "play_music";`, + ` = $(v:) video -> "play_video";`, + ` = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("returns both completions after shared prefix", () => { + const result = matchGrammarCompletion(grammar, "play "); + expect(result.completions.sort()).toEqual(["music", "video"]); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("wildcard with terminator", () => { + // Wildcard between string parts: "play $(name) now" + const g = ` = play $(name) now -> { name: name };`; + const grammar = loadGrammarRules("test.grammar", g); + + it("returns wildcard property (not terminator) when only separator follows wildcard start", () => { + // "play " — the trailing space is only a separator, not valid + // wildcard content, so the wildcard can't finalize and we fall + // through to the property-completion path instead of offering + // the terminator string. + const result = matchGrammarCompletion(grammar, "play "); + expect(result.completions).toEqual([]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("returns terminator with matchedPrefixLength tracking wildcard text", () => { + const result = matchGrammarCompletion(grammar, "play hello"); + expect(result.completions).toEqual(["now"]); + // Wildcard consumed "hello" — matchedPrefixLength includes it + expect(result.matchedPrefixLength).toBe(10); + }); + + it("returns terminator with matchedPrefixLength for trailing space", () => { + const result = matchGrammarCompletion(grammar, "play hello "); + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(11); + }); + }); + + describe("wildcard without terminator", () => { + const g = ` = play $(name) -> { name: name };`; + const grammar = loadGrammarRules("test.grammar", g); + + it("returns start rule for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("returns property completion for separator-only trailing wildcard", () => { + // The trailing space is not valid wildcard content, so the + // wildcard can't finalize. The else-branch produces a + // property completion instead, setting matchedPrefixLength to + // the wildcard start position. + const result = matchGrammarCompletion(grammar, "play "); + expect(result.completions).toHaveLength(0); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("CJK multi-part with nested rule", () => { + // CJK requires multi-part grammar for meaningful matchedPrefixLength + const g = [ + ` [spacing=auto] = $(v:) 音楽 -> true;`, + ` [spacing=auto] = 再生 -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("returns verb for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toEqual(["再生"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("returns noun completion after CJK verb typed", () => { + const result = matchGrammarCompletion(grammar, "再生"); + expect(result.completions).toEqual(["音楽"]); + // "再生" is 2 chars; matchedPrefixLength reflects position after verb + expect(result.matchedPrefixLength).toBe(2); + }); + + it("returns noun completion after CJK verb with space", () => { + const result = matchGrammarCompletion(grammar, "再生 "); + expect(result.completions).toEqual(["音楽"]); + expect(result.matchedPrefixLength).toBe(2); + }); + + it("returns no completions for exact match", () => { + const result = matchGrammarCompletion(grammar, "再生音楽"); + expect(result.completions).toHaveLength(0); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("CJK single string part", () => { + // Single string part — only the first word is offered initially. + // After the first word matches, the remaining words are offered. + const g = ` [spacing=auto] = 再生 音楽 -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("returns first word for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toEqual(["再生"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("returns remaining words for partial CJK prefix", () => { + const result = matchGrammarCompletion(grammar, "再生"); + // tryPartialStringMatch splits the multi-word part: "再生" + // is consumed (2 chars), "音楽" remains as the completion. + expect(result.completions).toEqual(["音楽"]); + expect(result.matchedPrefixLength).toBe(2); + }); + }); + + describe("CJK wildcard", () => { + const g = ` [spacing=auto] = 再生 $(name) 停止 -> { name: name };`; + const grammar = loadGrammarRules("test.grammar", g); + + it("returns property completion when only separator follows CJK wildcard start", () => { + // Same as the Latin case: trailing space is a separator, not + // valid wildcard content, so the terminator isn't offered. + const result = matchGrammarCompletion(grammar, "再生 "); + expect(result.completions).toEqual([]); + expect(result.matchedPrefixLength).toBe(2); + }); + + it("returns terminator after CJK prefix + wildcard text", () => { + const result = matchGrammarCompletion(grammar, "再生 hello"); + expect(result.completions).toEqual(["停止"]); + expect(result.matchedPrefixLength).toBe(8); + }); + }); + + describe("separatorMode - Latin multi-part", () => { + // Latin grammar: "play" → "music" requires a space separator + const g = [ + ` = $(v:) music -> true;`, + ` = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports separatorMode for Latin 'play' → 'music'", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toEqual(["music"]); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("reports separatorMode even when trailing space exists", () => { + const result = matchGrammarCompletion(grammar, "play "); + expect(result.completions).toEqual(["music"]); + // matchedPrefixLength is 4 ("play"); the trailing space is + // unmatched content beyond that boundary. separatorMode + // describes the boundary at matchedPrefixLength, so it is + // "spacePunctuation" (Latin "y" → "m" needs a separator). + expect(result.matchedPrefixLength).toBe(4); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("reports optional separatorMode for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.completions).toEqual(["play"]); + expect(result.separatorMode).toBe("optional"); + }); + + it("reports optional separatorMode for partial prefix match", () => { + // "pl" matches partially → the completion replaces from state.index, + // so no separator needed (user is typing the keyword) + const result = matchGrammarCompletion(grammar, "pl"); + expect(result.completions).toEqual(["play"]); + expect(result.separatorMode).toBe("optional"); + }); + }); + + describe("separatorMode - CJK multi-part", () => { + // CJK grammar: "再生" → "音楽" does NOT require a space separator + const g = [ + ` [spacing=auto] = $(v:) 音楽 -> true;`, + ` [spacing=auto] = 再生 -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports optional separatorMode for CJK '再生' → '音楽'", () => { + const result = matchGrammarCompletion(grammar, "再生"); + expect(result.completions).toEqual(["音楽"]); + // CJK → CJK in auto mode: separator optional + expect(result.separatorMode).toBe("optional"); + }); + }); + + describe("separatorMode - mixed scripts", () => { + // Latin followed by CJK: no separator needed in auto mode + const g = [ + ` [spacing=auto] = $(v:) 音楽 -> true;`, + ` [spacing=auto] = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports optional separatorMode for Latin 'play' → CJK '音楽'", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toEqual(["音楽"]); + // Latin → CJK in auto mode: different scripts, separator optional + expect(result.separatorMode).toBe("optional"); + }); + }); + + describe("separatorMode - spacing=required", () => { + const g = [ + ` [spacing=required] = $(v:) music -> true;`, + ` [spacing=required] = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports separatorMode when spacing=required", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toEqual(["music"]); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + }); + + describe("separatorMode - spacing=optional", () => { + const g = [ + ` [spacing=optional] = $(v:) music -> true;`, + ` [spacing=optional] = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports optional separatorMode when spacing=optional", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.completions).toEqual(["music"]); + expect(result.separatorMode).toBe("optional"); + }); + }); + + describe("separatorMode - wildcard entity", () => { + // Grammar where the completion is a wildcard entity (not a static string). + // separatorMode describes the boundary at matchedPrefixLength. + const g = [ + `entity TrackName;`, + ` = play $(name:TrackName) -> { actionName: "play", parameters: { name } };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("reports separatorMode for 'play' before wildcard", () => { + const result = matchGrammarCompletion(grammar, "play"); + expect(result.properties?.length).toBeGreaterThan(0); + // matchedPrefixLength=4; boundary "y" → entity needs separator. + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("reports separatorMode for 'play ' before wildcard", () => { + // matchedPrefixLength=4 ("play"); the trailing space is + // beyond that boundary. separatorMode describes the + // boundary at matchedPrefixLength: "y" → entity → "spacePunctuation". + const result = matchGrammarCompletion(grammar, "play "); + expect(result.properties?.length).toBeGreaterThan(0); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + }); +}); diff --git a/ts/packages/actionGrammarCompiler/README.md b/ts/packages/actionGrammarCompiler/README.md index 7851dd3dc..d0eff3bc7 100644 --- a/ts/packages/actionGrammarCompiler/README.md +++ b/ts/packages/actionGrammarCompiler/README.md @@ -9,7 +9,7 @@ The package provides two CLI entry points: - **`agc`** — production binary - **`agc-dev`** — development binary (with ts-node loader) -Both support a legacy invocation style: if the first argument isn't a recognized command name, the `compile` command is assumed automatically. +Both support a shortcut invocation style: if the first argument isn't a recognized command name, the `compile` command is assumed automatically. ## Commands diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index cfb26b5b1..9b5b0e155 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -17,7 +17,7 @@ import { AppAgentManifest, TypeAgentAction, AppAgentInitSettings, - CompletionGroup, + CompletionGroups, } from "@typeagent/agent-sdk"; import { @@ -170,7 +170,7 @@ export function createAgentRpcServer( } return agent.getCommands(getSessionContextShim(param)); }, - async getCommandCompletion(param): Promise { + async getCommandCompletion(param): Promise { if (agent.getCommandCompletion === undefined) { throw new Error("Invalid invocation of getCommandCompletion"); } diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index 72e849258..9a73f19cc 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -20,7 +20,7 @@ import { StorageListOptions, TemplateSchema, TypeAgentAction, - CompletionGroup, + CompletionGroups, ResolveEntityResult, } from "@typeagent/agent-sdk"; import { AgentInterfaceFunctionName } from "./server.js"; @@ -182,7 +182,7 @@ export type AgentInvokeFunctions = { params: ParsedCommandParams; names: string[]; }, - ): Promise; + ): Promise; executeCommand( param: Partial & { commands: string[]; diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 68ea496de..970309219 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -49,6 +49,32 @@ export type CommandDescriptors = //=========================================== // Command APIs //=========================================== + +// Describes what kind of separator is required between the matched prefix +// and the completion text. The frontend uses this to decide when to show +// the completion menu. +// +// "space" — whitespace required (default when omitted). +// Used for commands, flags, agent names. +// "spacePunctuation" — whitespace or Unicode punctuation ([\s\p{P}]) +// required. Used by the grammar matcher for +// Latin-script completions. +// "optional" — separator accepted but not required; menu shown +// immediately. Used for CJK / mixed-script +// grammar completions. +// "none" — no separator at all; menu shown immediately. +// Used for [spacing=none] grammars. +export type SeparatorMode = "space" | "spacePunctuation" | "optional" | "none"; + +// Controls when the session considers a typed completion "committed" and +// triggers a re-fetch for the next hierarchical level. +// "explicit" — the user must type an explicit delimiter (e.g. space or +// punctuation) after the matched token to commit it. +// Suppresses eager re-fetch on unique match. +// "eager" — commit as soon as the typed prefix uniquely satisfies a +// completion. Re-fetches immediately for the next level. +export type CommitMode = "explicit" | "eager"; + export type CompletionGroup = { name: string; // The group name for the completion completions: string[]; // The list of completions in the group @@ -58,6 +84,33 @@ export type CompletionGroup = { kind?: "literal" | "entity"; // Whether completions are fixed grammar tokens or entity values from agents }; +// Wraps an array of CompletionGroups with shared metadata that applies +// uniformly to all groups in the response. +export type CompletionGroups = { + groups: CompletionGroup[]; + // Number of characters of the input consumed by the grammar/command parser + // before the completion point. When present, the shell inserts + // completions at this offset, replacing space-based heuristics that fail + // for CJK and other non-space-delimited scripts. + matchedPrefixLength?: number | undefined; + // What kind of separator is required between the matched prefix and + // the completion text. When omitted, defaults to "space" (whitespace + // required before completions are shown). See SeparatorMode. + separatorMode?: SeparatorMode | undefined; + // True when the completions form a closed set — if the user types + // something not in the list, no further completions can exist + // beyond it. When true and the user types something that doesn't + // prefix-match any completion, the caller can skip re-fetching. + // False or undefined means the parser can continue past + // unrecognized input and find more completions. + closedSet?: boolean | undefined; + // Controls when a uniquely-satisfied completion triggers a re-fetch + // for the next hierarchical level. See CommitMode. + // When omitted, the dispatcher decides (typically "explicit" for + // command/parameter completions). + commitMode?: CommitMode | undefined; +}; + export interface AppAgentCommandInterface { // Get the command descriptors getCommands(context: SessionContext): Promise; @@ -68,7 +121,7 @@ export interface AppAgentCommandInterface { params: ParsedCommandParams | undefined, names: string[], // array of or -- or -- for completion context: SessionContext, - ): Promise; + ): Promise; // Execute a resolved command. Exception from the execution are treated as errors and displayed to the user. executeCommand( diff --git a/ts/packages/agentSdk/src/display.ts b/ts/packages/agentSdk/src/display.ts index d1917e2b8..ed818e080 100644 --- a/ts/packages/agentSdk/src/display.ts +++ b/ts/packages/agentSdk/src/display.ts @@ -58,28 +58,6 @@ export type ClientAction = | "automate-phone-ui" | "open-folder"; -/** - * Given a TypedDisplayContent, find the content for a preferred type. - * Checks alternates first, then falls back to the primary content if it matches. - * Returns undefined if the preferred type is not available. - */ -export function getContentForType( - content: TypedDisplayContent, - preferredType: DisplayType, -): MessageContent | undefined { - if (content.alternates) { - for (const alt of content.alternates) { - if (alt.type === preferredType) { - return alt.content; - } - } - } - if (content.type === preferredType) { - return content.content; - } - return undefined; -} - export interface ActionIO { // Set the display to the content provided setDisplay(content: DisplayContent): void; diff --git a/ts/packages/agentSdk/src/helpers/commandHelpers.ts b/ts/packages/agentSdk/src/helpers/commandHelpers.ts index 0ed54947b..0f45a1b48 100644 --- a/ts/packages/agentSdk/src/helpers/commandHelpers.ts +++ b/ts/packages/agentSdk/src/helpers/commandHelpers.ts @@ -7,8 +7,27 @@ import { CommandDescriptor, CommandDescriptors, CommandDescriptorTable, - CompletionGroup, + CompletionGroups, + SeparatorMode, } from "../command.js"; + +// Merge two SeparatorMode values — the mode requiring the strongest +// separator wins (i.e. the mode that demands the most from the user). +// Priority: "space" > "spacePunctuation" > "optional" > "none" > undefined. +export function mergeSeparatorMode( + a: SeparatorMode | undefined, + b: SeparatorMode | undefined, +): SeparatorMode | undefined { + if (a === undefined) return b; + if (b === undefined) return a; + const order: Record = { + space: 3, + spacePunctuation: 2, + optional: 1, + none: 0, + }; + return order[a] >= order[b] ? a : b; +} import { ParameterDefinitions, ParsedCommandParams, @@ -42,7 +61,7 @@ export type CommandHandler = CommandDescriptor & { context: SessionContext, params: PartialParsedCommandParams, names: string[], - ): Promise; + ): Promise; }; type CommandHandlerTypes = CommandHandlerNoParams | CommandHandler; @@ -171,7 +190,11 @@ export function getCommandInterface( context: SessionContext, ) => { const handler = getCommandHandler(handlers, commands); - return handler.getCompletion?.(context, params, names) ?? []; + return ( + handler.getCompletion?.(context, params, names) ?? { + groups: [], + } + ); }; } return commandInterface; diff --git a/ts/packages/agentSdk/src/helpers/displayHelpers.ts b/ts/packages/agentSdk/src/helpers/displayHelpers.ts index accca2fb7..b84f86df2 100644 --- a/ts/packages/agentSdk/src/helpers/displayHelpers.ts +++ b/ts/packages/agentSdk/src/helpers/displayHelpers.ts @@ -6,9 +6,33 @@ import { DisplayAppendMode, DisplayContent, DisplayMessageKind, + DisplayType, MessageContent, + TypedDisplayContent, } from "../display.js"; +/** + * Given a TypedDisplayContent, find the content for a preferred type. + * Checks alternates first, then falls back to the primary content if it matches. + * Returns undefined if the preferred type is not available. + */ +export function getContentForType( + content: TypedDisplayContent, + preferredType: DisplayType, +): MessageContent | undefined { + if (content.alternates) { + for (const alt of content.alternates) { + if (alt.type === preferredType) { + return alt.content; + } + } + } + if (content.type === preferredType) { + return content.content; + } + return undefined; +} + function gatherMessages(callback: (log: (message?: string) => void) => void) { const messages: (string | undefined)[] = []; callback((message?: string) => { diff --git a/ts/packages/agentSdk/src/index.ts b/ts/packages/agentSdk/src/index.ts index 70a0f5b82..bed6aa179 100644 --- a/ts/packages/agentSdk/src/index.ts +++ b/ts/packages/agentSdk/src/index.ts @@ -29,7 +29,10 @@ export { CommandDescriptors, CommandDescriptorTable, AppAgentCommandInterface, + CommitMode, CompletionGroup, + CompletionGroups, + SeparatorMode, } from "./command.js"; export { @@ -51,7 +54,6 @@ export { TypedDisplayContent, DisplayAppendMode, DisplayMessageKind, - getContentForType, } from "./display.js"; export { diff --git a/ts/packages/agentSdk/src/parameters.ts b/ts/packages/agentSdk/src/parameters.ts index 7d21e7fa5..ec817a1b5 100644 --- a/ts/packages/agentSdk/src/parameters.ts +++ b/ts/packages/agentSdk/src/parameters.ts @@ -140,12 +140,6 @@ type ArgsOutput = T extends ArgDefinitions export type ParsedCommandParams = { args: ArgsOutput; flags: FlagsOutput; - - // Information for partial command completion. - tokens: string[]; // The list of tokens parsed from the command. - lastCompletableParam: string | undefined; // The last parameter that was parsed that can be completed. - lastParamImplicitQuotes: boolean; // If the last parameter is implicitly quoted. - nextArgs: string[]; // A list of potential arguments next. }; export type PartialParsedCommandParams = diff --git a/ts/packages/agents/browser/src/agent/browserActionHandler.mts b/ts/packages/agents/browser/src/agent/browserActionHandler.mts index 365e3713a..79c289c58 100644 --- a/ts/packages/agents/browser/src/agent/browserActionHandler.mts +++ b/ts/packages/agents/browser/src/agent/browserActionHandler.mts @@ -1384,10 +1384,6 @@ async function changeSearchProvider( const params: ParsedCommandParams = { args: { provider: `${action.parameters.name}` }, flags: {}, - tokens: [], - lastCompletableParam: undefined, - lastParamImplicitQuotes: false, - nextArgs: [], }; await cmd.run(context, params); diff --git a/ts/packages/agents/taskflow/src/taskFlowRunner.mts b/ts/packages/agents/taskflow/src/taskFlowRunner.mts index 999b7f8e0..804405326 100644 --- a/ts/packages/agents/taskflow/src/taskFlowRunner.mts +++ b/ts/packages/agents/taskflow/src/taskFlowRunner.mts @@ -10,11 +10,11 @@ import type { } from "@typeagent/dispatcher-types"; import { DisplayAppendMode, - getContentForType, type DisplayContent, type MessageContent, type TypedDisplayContent, } from "@typeagent/agent-sdk"; +import { getContentForType } from "@typeagent/agent-sdk/helpers/display"; import { convert } from "html-to-text"; // ── Text utilities ─────────────────────────────────────────────────────────── diff --git a/ts/packages/cache/src/cache/cache.ts b/ts/packages/cache/src/cache/cache.ts index 3046c7e91..be93c2a52 100644 --- a/ts/packages/cache/src/cache/cache.ts +++ b/ts/packages/cache/src/cache/cache.ts @@ -589,6 +589,7 @@ export class AgentCache { // Otherwise use completion-based construction store debug(`match: Using completion-based construction store`); const store = this._constructionStore; + const grammarStore = this._grammarStore; if (store.isEnabled()) { const constructionMatches = store.match(request, options); if (constructionMatches.length > 0) { @@ -598,18 +599,19 @@ export class AgentCache { return rest; }); } + if (!grammarStore.isEnabled()) { + return []; + } + } else if (!grammarStore.isEnabled()) { + throw new Error("AgentCache is disabled"); } // Fallback to grammar store if construction store has no matches - const grammarStore = this._grammarStore; - if (grammarStore.isEnabled()) { - return this._grammarStore.match(request, options); - } - throw new Error("AgentCache is disabled"); + return grammarStore.match(request, options); } public completion( - requestPrefix: string | undefined, + requestPrefix: string, options?: MatchOptions, ): CompletionResult | undefined { // If NFA grammar system is configured, only use grammar store diff --git a/ts/packages/cache/src/cache/constructionStore.ts b/ts/packages/cache/src/cache/constructionStore.ts index 4c48be78a..be13021d9 100644 --- a/ts/packages/cache/src/cache/constructionStore.ts +++ b/ts/packages/cache/src/cache/constructionStore.ts @@ -395,10 +395,7 @@ export class ConstructionStoreImpl implements ConstructionStore { return sortedMatches; } - public completion( - requestPrefix: string | undefined, - options?: MatchOptions, - ) { + public completion(requestPrefix: string, options?: MatchOptions) { const cacheCompletion = this.cache?.completion(requestPrefix, options); const builtInCompletion = this.builtInCache?.completion( requestPrefix, diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index 41f2b2059..758da8cf2 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -18,6 +18,8 @@ import { } from "action-grammar"; const debug = registerDebug("typeagent:cache:grammarStore"); +import { SeparatorMode } from "@typeagent/agent-sdk"; +import { mergeSeparatorMode } from "@typeagent/agent-sdk/helpers/command"; import { CompletionProperty, CompletionResult, @@ -185,12 +187,12 @@ export class GrammarStoreImpl implements GrammarStore { ? "DFA" : this.useNFA && entry.nfa ? "NFA" - : "legacy"; + : "simple"; debug( `Matching "${request}" against ${schemaName} (${matchMode}) - NFA states: ${entry.nfa?.states.length || 0}, DFA states: ${entry.dfa?.states.length || 0}, rules: ${entry.grammar.rules.length}`, ); - // Choose matcher: DFA > NFA > legacy + // Choose matcher: DFA > NFA > simple let grammarMatches; if (this.useDFA && entry.dfa) { const tokens = tokenizeRequest(request); @@ -260,7 +262,7 @@ export class GrammarStoreImpl implements GrammarStore { } public completion( - requestPrefix: string | undefined, + requestPrefix: string, options?: MatchOptions, ): CompletionResult | undefined { if (!this.enabled) { @@ -272,6 +274,9 @@ export class GrammarStoreImpl implements GrammarStore { } const completions: string[] = []; const properties: CompletionProperty[] = []; + let matchedPrefixLength = 0; + let separatorMode: SeparatorMode | undefined; + let closedSet: boolean | undefined; const filter = new Set(namespaceKeys); for (const [name, entry] of this.grammars) { if (filter && !filter.has(name)) { @@ -280,11 +285,10 @@ export class GrammarStoreImpl implements GrammarStore { if (this.useDFA && entry.dfa) { // DFA-based completions const tokens = requestPrefix - ? requestPrefix - .trim() - .split(/\s+/) - .filter((t) => t.length > 0) - : []; + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + const dfaCompResult = getDFACompletions(entry.dfa, tokens); if ( dfaCompResult.completions && @@ -313,11 +317,10 @@ export class GrammarStoreImpl implements GrammarStore { } else if (this.useNFA && entry.nfa) { // NFA-based completions: tokenize into complete whole tokens const tokens = requestPrefix - ? requestPrefix - .trim() - .split(/\s+/) - .filter((t) => t.length > 0) - : []; + .trim() + .split(/\s+/) + .filter((t) => t.length > 0); + const nfaResult = computeNFACompletions(entry.nfa, tokens); if (nfaResult.completions.length > 0) { completions.push(...nfaResult.completions); @@ -345,31 +348,55 @@ export class GrammarStoreImpl implements GrammarStore { } } } else { - // Legacy grammar-based completions + // simple grammar-based completions const partial = matchGrammarCompletion( entry.grammar, - requestPrefix ?? "", + requestPrefix, + matchedPrefixLength, ); - if (partial.completions.length > 0) { - completions.push(...partial.completions); + const partialPrefixLength = partial.matchedPrefixLength ?? 0; + if (partialPrefixLength > matchedPrefixLength) { + // Longer prefix — discard shorter-match results. + matchedPrefixLength = partialPrefixLength; + completions.length = 0; + properties.length = 0; + separatorMode = undefined; + closedSet = undefined; } - if ( - partial.properties !== undefined && - partial.properties.length > 0 - ) { - const { schemaName } = splitSchemaNamespaceKey(name); - for (const p of partial.properties) { - const action: any = p.match; - properties.push({ - actions: [ - createExecutableAction( - schemaName, - action.actionName, - action.parameters, - ), - ], - names: p.propertyNames, - }); + if (partialPrefixLength === matchedPrefixLength) { + completions.push(...partial.completions); + if (partial.separatorMode !== undefined) { + separatorMode = mergeSeparatorMode( + separatorMode, + partial.separatorMode, + ); + } + // AND-merge: closed set only when all grammar + // results at this prefix length are closed sets. + if (partial.closedSet !== undefined) { + closedSet = + closedSet === undefined + ? partial.closedSet + : closedSet && partial.closedSet; + } + if ( + partial.properties !== undefined && + partial.properties.length > 0 + ) { + const { schemaName } = splitSchemaNamespaceKey(name); + for (const p of partial.properties) { + const action: any = p.match; + properties.push({ + actions: [ + createExecutableAction( + schemaName, + action.actionName, + action.parameters, + ), + ], + names: p.propertyNames, + }); + } } } } @@ -378,6 +405,9 @@ export class GrammarStoreImpl implements GrammarStore { return { completions, properties, + matchedPrefixLength, + separatorMode, + closedSet, }; } } diff --git a/ts/packages/cache/src/cache/types.ts b/ts/packages/cache/src/cache/types.ts index ef5b1b863..739086b0f 100644 --- a/ts/packages/cache/src/cache/types.ts +++ b/ts/packages/cache/src/cache/types.ts @@ -13,6 +13,7 @@ export type MatchResult = { entityWildcardPropertyNames: string[]; conflictValues?: [string, ParamValueType[]][] | undefined; partialPartCount?: number | undefined; // Only used for partial match + partialMatchedCurrent?: number | undefined; // Character offset where partial matching stopped }; export interface GrammarStore { diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index be3781bc5..0d35d3d67 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { SeparatorMode } from "@typeagent/agent-sdk"; +import { mergeSeparatorMode } from "@typeagent/agent-sdk/helpers/command"; +import { needsSeparatorInAutoMode } from "action-grammar"; import { ExecutableAction, HistoryContext, @@ -23,9 +26,11 @@ import { ConstructionCacheJSON, constructionCacheJSONVersion, } from "./constructionJSONTypes.js"; +import { getLanguageTools } from "../utils/language.js"; const debugConst = registerDebug("typeagent:const"); const debugConstMatchStat = registerDebug("typeagent:const:match:stat"); const debugCompletion = registerDebug("typeagent:const:completion"); + // Agent Cache define the namespace policy. At the cache, it just combine the keys into a string for lookup. function getConstructionNamespace(namespaceKeys: string[]) { // Combine the namespace keys into a string using | as the separator. Use to filter easily when @@ -73,6 +78,16 @@ export type CompletionProperty = { export type CompletionResult = { completions: string[]; properties?: CompletionProperty[] | undefined; + // Characters consumed by the grammar before the completion point. + matchedPrefixLength?: number | undefined; + // What kind of separator is required between the already-typed prefix + // and the completion text. See SeparatorMode in @typeagent/agent-sdk. + separatorMode?: SeparatorMode | undefined; + // True when the completions form a closed set — if the user types + // something not in the list, no further completions can exist + // beyond it. False or undefined means the parser can continue + // past unrecognized input and find more completions. + closedSet?: boolean | undefined; }; export function mergeCompletionResults( @@ -85,6 +100,23 @@ export function mergeCompletionResults( if (second === undefined) { return first; } + // Eagerly discard shorter-prefix completions — consistent with the + // grammar matcher's approach. Only the source(s) with the longest + // matchedPrefixLength contribute completions. + const firstLen = first.matchedPrefixLength ?? 0; + const secondLen = second.matchedPrefixLength ?? 0; + if (firstLen > secondLen) { + return first; + } + if (secondLen > firstLen) { + return second; + } + // Same prefix length — merge completions from both sources. + const matchedPrefixLength = + first.matchedPrefixLength !== undefined || + second.matchedPrefixLength !== undefined + ? firstLen + : undefined; return { completions: [...first.completions, ...second.completions], properties: first.properties @@ -92,8 +124,19 @@ export function mergeCompletionResults( ? [...first.properties, ...second.properties] : first.properties : second.properties, + matchedPrefixLength, + separatorMode: mergeSeparatorMode( + first.separatorMode, + second.separatorMode, + ), + // Closed set only when both sources are closed sets. + closedSet: + first.closedSet !== undefined || second.closedSet !== undefined + ? (first.closedSet ?? false) && (second.closedSet ?? false) + : undefined, }; } + export class ConstructionCache { private readonly matchSetsByUid = new Map(); @@ -285,40 +328,6 @@ export class ConstructionCache { return count; } - // For completion - private getPrefix(namespaceKeys?: string[]): string[] { - if (namespaceKeys?.length === 0) { - return []; - } - const prefix = new Set(); - const filter = namespaceKeys ? new Set(namespaceKeys) : undefined; - for (const [ - name, - constructionNamespace, - ] of this.constructionNamespaces.entries()) { - const keys = getNamespaceKeys(name); - if (filter && keys.some((key) => !filter.has(key))) { - continue; - } - - for (const construction of constructionNamespace.constructions) { - for (const part of construction.parts) { - if (part.optional) { - continue; - } - if (isMatchPart(part) && part.matchSet) { - // For match parts, we can use the match set name as the prefix - for (const match of part.matchSet.matches.values()) { - prefix.add(match); - } - } - break; - } - } - } - return [...prefix.values()]; - } - // For matching public match( request: string, @@ -361,21 +370,12 @@ export class ConstructionCache { } public completion( - requestPrefix: string | undefined, + requestPrefix: string, options?: MatchOptions, ): CompletionResult | undefined { debugCompletion(`Request completion for prefix: '${requestPrefix}'`); const namespaceKeys = options?.namespaceKeys; debugCompletion(`Request completion namespace keys`, namespaceKeys); - if (!requestPrefix) { - const completions = this.getPrefix(namespaceKeys); - - return completions.length > 0 - ? { - completions, - } - : undefined; - } const results = this.match(requestPrefix, options, true); @@ -383,10 +383,37 @@ export class ConstructionCache { `Request completion construction match: ${results.length}`, ); + if (results.length === 0) { + return undefined; + } + + // Track the furthest character position consumed across all + // matching constructions. When a longer match is found, all + // previously accumulated completions from shorter matches are + // discarded — mirroring the grammar matcher's approach. + let maxPrefixLength = 0; const completionProperty: CompletionProperty[] = []; const requestText: string[] = []; + let separatorMode: SeparatorMode | undefined; + // Whether the accumulated completions form a closed set. + // Starts true; set to false when property/wildcard completions + // are added (entity values are external). Reset to true when + // maxPrefixLength advances (old candidates discarded). + let closedSet: boolean = true; + + function updateMaxPrefixLength(prefixLength: number): void { + if (prefixLength > maxPrefixLength) { + maxPrefixLength = prefixLength; + requestText.length = 0; + completionProperty.length = 0; + separatorMode = undefined; + closedSet = true; + } + } + for (const result of results) { - const { construction, partialPartCount } = result; + const { construction, partialPartCount, partialMatchedCurrent } = + result; if (partialPartCount === undefined) { throw new Error( "Internal Error: Partial part count is undefined", @@ -394,7 +421,17 @@ export class ConstructionCache { } if (partialPartCount === construction.parts.length) { - continue; // No more parts to complete + // Exact match — all parts matched. Nothing to complete, + // but advance maxPrefixLength so shorter candidates are + // discarded. + updateMaxPrefixLength(requestPrefix.length); + continue; + } + + const candidatePrefixLength = partialMatchedCurrent ?? 0; + updateMaxPrefixLength(candidatePrefixLength); + if (candidatePrefixLength !== maxPrefixLength) { + continue; // Shorter than the best match — skip } const nextPart = construction.parts[partialPartCount]; @@ -402,7 +439,33 @@ export class ConstructionCache { if (nextPart.wildcardMode <= WildcardMode.Enabled) { const partCompletions = nextPart.getCompletion(); if (partCompletions) { - requestText.push(...partCompletions); + const langTools = getLanguageTools("en"); + const rejectReferences = options?.rejectReferences ?? true; + for (const completionText of partCompletions) { + // We would have rejected the value if this part is captured. + if ( + nextPart.capture && + rejectReferences && + langTools?.possibleReferentialPhrase(completionText) + ) { + continue; + } + requestText.push(completionText); + // Determine separator mode for this candidate. + if ( + candidatePrefixLength > 0 && + completionText.length > 0 + ) { + const needsSep = needsSeparatorInAutoMode( + requestPrefix[candidatePrefixLength - 1], + completionText[0], + ); + separatorMode = mergeSeparatorMode( + separatorMode, + needsSep ? "spacePunctuation" : "optional", + ); + } + } } } @@ -435,12 +498,31 @@ export class ConstructionCache { actions: result.match.actions, names: queryPropertyNames, }); + // Determine separator mode for the property/entity slot. + // Use "a" as a representative word character since the + // actual entity value is unknown. + if (candidatePrefixLength > 0) { + const needsSep = needsSeparatorInAutoMode( + requestPrefix[candidatePrefixLength - 1], + "a", + ); + separatorMode = mergeSeparatorMode( + separatorMode, + needsSep ? "spacePunctuation" : "optional", + ); + } + // Property/wildcard completions are not a closed set — + // entity values are external. + closedSet = false; } } return { completions: requestText, properties: completionProperty, + matchedPrefixLength: maxPrefixLength, + separatorMode, + closedSet, }; } diff --git a/ts/packages/cache/src/constructions/constructionMatch.ts b/ts/packages/cache/src/constructions/constructionMatch.ts index 83c445b7a..191a24923 100644 --- a/ts/packages/cache/src/constructions/constructionMatch.ts +++ b/ts/packages/cache/src/constructions/constructionMatch.ts @@ -64,6 +64,7 @@ export function matchParts( if (values !== undefined) { if (config.partial) { values.partialPartCount = state.matchedStart.length; + values.matchedCurrent = state.matchedCurrent; } return values; } @@ -167,6 +168,29 @@ function finishMatchParts( } if (config.partial) { + // If this is a wildcard-enabled part and there is + // non-separator text remaining, try to advance past + // the wildcard by looking for the next literal part. + // This mirrors the grammar matcher's behaviour where + // wildcards consume text and the following literal + // part is offered as a completion. + if ( + isWildcardEnabled(config, part.wildcardMode) && + state.matchedCurrent < request.length && + !isSpaceOrPunctuationRange( + request, + state.matchedCurrent, + request.length, + ) + ) { + state.matchedStart.push(state.matchedCurrent); + state.pendingWildcard = findPendingWildcard( + request, + state.matchedCurrent, + ); + continue; + } + // For partial, act as if we have matched all the parts, and breaking out of the loop to finish the match. break; } @@ -201,6 +225,15 @@ function finishMatchParts( } if (state.pendingWildcard === -1) { + if (config.partial && state.matchedStart.length < parts.length) { + // Partial mode broke out of the loop before matching all + // parts. Accept the partial match even when the remaining + // text contains non-separator characters — the completion + // layer will return the next part's candidates and the + // caller (UI) filters by the remaining text, matching the + // grammar matcher's behaviour. + return true; + } // The tail should only be space or punctuation return ( state.matchedCurrent === request.length || @@ -217,11 +250,12 @@ function finishMatchParts( const wildcardMatch = wildcardRegex.exec(wildcardRange); if (wildcardMatch !== null) { // Update the state in case we need to backtrack because value translation failed. + const wildcardPart = parts[state.matchedStart.length - 1]; if ( !captureWildcardMatch( state, wildcardMatch[1], - config.rejectReferences, + isRejectReference(config, wildcardPart.wildcardMode), ) ) { return false; @@ -367,7 +401,7 @@ function backtrack( // Check if it is optional, backtrack to before the optional and resume the search if (backtrackPart.optional) { // REVIEW: the constructor enforced that parts before and after a wildcard can't be optional. - // Otherwise, we need to restor pendingWildcard state here. + // Otherwise, we need to restore pendingWildcard state here. state.matchedStart.push(-1); return true; } diff --git a/ts/packages/cache/src/constructions/constructionValue.ts b/ts/packages/cache/src/constructions/constructionValue.ts index fa8f89955..0a7914786 100644 --- a/ts/packages/cache/src/constructions/constructionValue.ts +++ b/ts/packages/cache/src/constructions/constructionValue.ts @@ -37,6 +37,7 @@ export type MatchedValues = { matchedCount: number; wildcardCharCount: number; partialPartCount?: number; // Only used for partial match + matchedCurrent?: number; // Character offset where matching stopped (partial only) }; export function matchedValues( diff --git a/ts/packages/cache/src/constructions/constructions.ts b/ts/packages/cache/src/constructions/constructions.ts index eec692655..f5ea1e159 100644 --- a/ts/packages/cache/src/constructions/constructions.ts +++ b/ts/packages/cache/src/constructions/constructions.ts @@ -160,6 +160,7 @@ export class Construction { nonOptionalCount: this.parts.filter((p) => !p.optional).length, implicitParameterCount: this.implicitParameterCount, partialPartCount: matchedValues.partialPartCount, + partialMatchedCurrent: matchedValues.matchedCurrent, }, ]; } diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts new file mode 100644 index 000000000..da7866045 --- /dev/null +++ b/ts/packages/cache/test/completion.spec.ts @@ -0,0 +1,781 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + Construction, + WildcardMode, +} from "../src/constructions/constructions.js"; +import { + ConstructionCache, + MatchOptions, +} from "../src/constructions/constructionCache.js"; +import { + createMatchPart, + MatchPart, + MatchSet, + TransformInfo, +} from "../src/constructions/matchPart.js"; + +function makeTransformInfo(name: string): TransformInfo { + return { + namespace: "test", + transformName: name, + partCount: 1, + }; +} + +function createEntityPart(name: string, transformName: string): MatchPart { + return new MatchPart(undefined, false, WildcardMode.Entity, [ + makeTransformInfo(transformName), + ]); +} + +function createWildcardEnabledPartWithMatches( + matches: string[], + name: string, + transformName: string, +): MatchPart { + const matchSet = new MatchSet(matches, name, true, undefined); + return new MatchPart(matchSet, false, WildcardMode.Enabled, [ + makeTransformInfo(transformName), + ]); +} + +function makeCache( + constructions: Construction[], + namespace: string[] = ["test"], +): ConstructionCache { + const cache = new ConstructionCache("test"); + for (const c of constructions) { + cache.addConstruction(namespace, c, true); + } + return cache; +} + +const defaultOptions: MatchOptions = { namespaceKeys: ["test"] }; + +describe("ConstructionCache.completion()", () => { + describe("empty prefix", () => { + it("returns first non-optional parts from all constructions", () => { + const c1 = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const c2 = Construction.create( + [createMatchPart(["stop"], "verb")], + new Map(), + ); + const cache = makeCache([c1, c2]); + const result = cache.completion("", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual(["play", "stop"]); + expect(result!.matchedPrefixLength).toBe(0); + expect(result!.closedSet).toBe(true); + }); + it("returns empty string prefix as undefined", () => { + const cache = new ConstructionCache("test"); + const result = cache.completion("", defaultOptions); + expect(result).toBeUndefined(); + }); + + it("skips optional leading parts", () => { + const optionalPart = createMatchPart(["please"], "polite", { + optional: true, + }); + const verbPart = createMatchPart(["play"], "verb"); + const c = Construction.create([optionalPart, verbPart], new Map()); + const cache = makeCache([c]); + const result = cache.completion("", defaultOptions); + expect(result).toBeDefined(); + // Should return the first non-optional part's completions + expect(result!.completions).toEqual(["play"]); + }); + }); + + describe("matchedPrefixLength", () => { + it("returns matchedPrefixLength matching the consumed prefix", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toContain("song"); + // The matcher consumes "play" (4 chars); the trailing space + // is a separator and not part of any match part. + expect(result!.matchedPrefixLength).toBe(4); + }); + + it("returns matchedPrefixLength for partial single-part match", () => { + const c = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const cache = makeCache([c]); + // "pl" partially matches "play" + const result = cache.completion("pl", defaultOptions); + expect(result).toBeDefined(); + if (result!.completions.length > 0) { + expect(result!.matchedPrefixLength).toBeGreaterThanOrEqual(0); + } + }); + + it("discards shorter-prefix completions when longer exists", () => { + // Construction 1: "play song" (two parts) + const c1 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song", "track"], "noun"), + ], + new Map(), + ); + // Construction 2: "play" (one part only, so exact match) + const c2 = Construction.create( + [createMatchPart(["play"], "verb2")], + new Map(), + ); + const cache = makeCache([c1, c2]); + // "play " is an exact match for c2, but partial for c1 + // c2 is exact → matchedPrefixLength = "play ".length = 5 + // c1 partial on second part → matchedPrefixLength = 5 + // Both should have the same prefix length + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.matchedPrefixLength).toBe(5); + }); + }); + + describe("closedSet", () => { + it("is true when all completions are from literal match parts", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song", "album"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.closedSet).toBe(true); + }); + + it("is false when entity wildcard properties are involved", () => { + const verbPart = createMatchPart(["play"], "verb"); + const entityPart = createEntityPart("entity", "songName"); + const c = Construction.create([verbPart, entityPart], new Map()); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.closedSet).toBe(false); + }); + + it("is false when wildcard-enabled part with property names is next", () => { + const verbPart = createMatchPart(["play"], "verb"); + const wildcardPart = createWildcardEnabledPartWithMatches( + ["rock", "pop"], + "genre", + "genreName", + ); + const c = Construction.create([verbPart, wildcardPart], new Map()); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + // The wildcard-enabled part has both completions AND property names + // Property names → closedSet becomes false + expect(result!.closedSet).toBe(false); + // Should still include the literal completions from the matchSet + expect(result!.completions.sort()).toEqual(["pop", "rock"]); + }); + }); + + describe("separatorMode", () => { + it("returns spacePunctuation when prefix ends with word char and completion starts with word char", () => { + // "play" ends with 'y' (Latin), "song" starts with 's' (Latin) + // → both are word-boundary scripts → needs separator + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + // The matcher consumes "play" (4 chars). The character at + // position 3 is 'y' (Latin) and "song" starts with 's' (Latin). + // Both are word-boundary scripts → spacePunctuation. + expect(result!.separatorMode).toBe("spacePunctuation"); + }); + + it("returns spacePunctuation between adjacent word characters", () => { + // Use a two-word single match part to get adjacent word chars + // "play" followed by completions starting with 's' + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + // "play" is 4 chars, no trailing space. + // If partial matching consumes "play" (4 chars), the next part "song" starts + // with 's'. Last prefix char is 'y' — both Latin → spacePunctuation + const result = cache.completion("play", defaultOptions); + expect(result).toBeDefined(); + if ( + result!.completions.length > 0 && + result!.matchedPrefixLength === 4 + ) { + // 'y' and 's' are both Latin word-boundary — needs separator + expect(result!.separatorMode).toBe("spacePunctuation"); + } + }); + + it("returns optional between non-word chars", () => { + // Prefix ends with punctuation, completions start with letter + // "!" is not a word-boundary script + const c = Construction.create( + [ + createMatchPart(["hey!"], "exclaim"), + createMatchPart(["world"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("hey! ", defaultOptions); + if (result && result.completions.length > 0) { + // ' ' is not a word char → optional + expect(result!.separatorMode).toBe("optional"); + } + }); + + it("returns spacePunctuation between adjacent digits", () => { + // Both '3' and '4' are digits → needsSeparatorInAutoMode + const c = Construction.create( + [ + createMatchPart(["item3"], "first"), + createMatchPart(["4ever"], "second"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("item3", defaultOptions); + if ( + result && + result.completions.length > 0 && + result.matchedPrefixLength === 5 + ) { + // '3' and '4' are digits → needs separator + expect(result!.separatorMode).toBe("spacePunctuation"); + } + }); + }); + + describe("completions content", () => { + it("returns next part completions for partial match", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song", "album", "track"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual([ + "album", + "song", + "track", + ]); + }); + + it("returns empty completions for exact match with no remaining parts", () => { + const c = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const cache = makeCache([c]); + // Exact match — nothing left to complete + const result = cache.completion("play", defaultOptions); + expect(result).toBeDefined(); + // Exact match advances maxPrefixLength to requestPrefix.length + expect(result!.matchedPrefixLength).toBe(4); + expect(result!.completions).toEqual([]); + }); + + it("returns completions from multiple constructions with same prefix length", () => { + const c1 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const c2 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["album"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c1, c2]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual(["album", "song"]); + }); + }); + + describe("properties", () => { + it("returns property names for entity wildcard parts", () => { + const verbPart = createMatchPart(["play"], "verb"); + const entityPart = createEntityPart("entity", "songName"); + const c = Construction.create([verbPart, entityPart], new Map()); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.properties).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("songName"); + }); + }); + + describe("namespace filtering", () => { + it("filters completions by namespace keys", () => { + // Use distinct match-set names to prevent merging across + // namespaces (same name + canBeMerged → shared MatchSet). + const c1 = Construction.create( + [createMatchPart(["play"], "verb1", { canBeMerged: false })], + new Map(), + ); + const c2 = Construction.create( + [createMatchPart(["stop"], "verb2", { canBeMerged: false })], + new Map(), + ); + const cache = new ConstructionCache("test"); + cache.addConstruction(["ns1"], c1, true); + cache.addConstruction(["ns2"], c2, true); + + const r1 = cache.completion("", { namespaceKeys: ["ns1"] }); + expect(r1).toBeDefined(); + expect(r1!.completions).toEqual(["play"]); + + const r2 = cache.completion("", { namespaceKeys: ["ns2"] }); + expect(r2).toBeDefined(); + expect(r2!.completions).toEqual(["stop"]); + }); + + it("returns completions from all namespaces when no filter", () => { + const c1 = Construction.create( + [createMatchPart(["play"], "verb1", { canBeMerged: false })], + new Map(), + ); + const c2 = Construction.create( + [createMatchPart(["stop"], "verb2", { canBeMerged: false })], + new Map(), + ); + const cache = new ConstructionCache("test"); + cache.addConstruction(["ns1"], c1, true); + cache.addConstruction(["ns2"], c2, true); + + const result = cache.completion("", {}); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual(["play", "stop"]); + }); + + it("returns no completions for empty namespace keys", () => { + const c = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("", { + namespaceKeys: [], + }); + // Empty namespace keys → no constructions match → no completions. + expect(result).toBeUndefined(); + }); + }); + + describe("progressive prefix matching", () => { + // Tests for progressive prefix lengths against a "play" + "song" + // construction. The match engine supports intra-part partial + // matching — a prefix like "p" returns completions from the + // first unmatched part (matchedPrefixLength=0) and the caller + // (UI) filters by the remaining text, matching the grammar + // matcher's behaviour. + + let cache: ConstructionCache; + beforeEach(() => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + cache = makeCache([c]); + }); + + it("prefix 'p' — partial prefix returns first part completions", () => { + const result = cache.completion("p", defaultOptions); + expect(result).toBeDefined(); + // "p" doesn't fully match "play" but the partial match + // succeeds and returns the first part's candidates. + // The caller filters by the remaining text ("p"). + expect(result!.completions).toContain("play"); + expect(result!.matchedPrefixLength).toBe(0); + expect(result!.closedSet).toBe(true); + }); + + it("prefix 'pl' — partial prefix returns first part completions", () => { + const result = cache.completion("pl", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toContain("play"); + expect(result!.matchedPrefixLength).toBe(0); + }); + + it("prefix 'play' — first part fully matched, offers second part", () => { + const result = cache.completion("play", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toEqual(["song"]); + expect(result!.matchedPrefixLength).toBe(4); + expect(result!.separatorMode).toBe("spacePunctuation"); + expect(result!.closedSet).toBe(true); + }); + + it("prefix 'play ' — trailing space ignored, still offers second part", () => { + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toEqual(["song"]); + // matchedPrefixLength stays at 4 (the space is a separator, + // not consumed by any part) + expect(result!.matchedPrefixLength).toBe(4); + expect(result!.separatorMode).toBe("spacePunctuation"); + }); + + it("prefix 'play s' — partial intra-part on second part, returns completions", () => { + const result = cache.completion("play s", defaultOptions); + expect(result).toBeDefined(); + // "play" is fully matched (4 chars), " s" remains as + // partial prefix for the second part. + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(4); + }); + + it("prefix 'play song' — exact full match, empty completions", () => { + const result = cache.completion("play song", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toEqual([]); + expect(result!.matchedPrefixLength).toBe(9); + expect(result!.closedSet).toBe(true); + }); + }); + + describe("multiple alternatives in a single part", () => { + it("offers all alternatives when first part is fully matched", () => { + const c = Construction.create( + [ + createMatchPart(["play", "start"], "verb"), + createMatchPart(["song", "track", "album"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + + const r1 = cache.completion("play", defaultOptions); + expect(r1).toBeDefined(); + expect(r1!.completions.sort()).toEqual(["album", "song", "track"]); + + const r2 = cache.completion("start", defaultOptions); + expect(r2).toBeDefined(); + expect(r2!.completions.sort()).toEqual(["album", "song", "track"]); + }); + }); + + describe("case insensitivity", () => { + it("matches prefix case-insensitively", () => { + const c = Construction.create( + [ + createMatchPart(["Play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("PLAY", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toEqual(["song"]); + expect(result!.matchedPrefixLength).toBe(4); + }); + }); + + describe("multi-part constructions", () => { + it("completes third part after matching first two", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["the"], "article"), + createMatchPart(["song", "album"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play the ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual(["album", "song"]); + }); + + it("returns merged match set completions after merge", () => { + // Two constructions with same structure merge their match sets + const c1 = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const c2 = Construction.create( + [createMatchPart(["stop"], "verb")], + new Map(), + ); + const cache = makeCache([c1, c2]); + // After merge, the match set should contain both "play" and "stop" + const result = cache.completion("", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual(["play", "stop"]); + }); + }); + + describe("wildcard completions", () => { + describe("entity wildcard after literal", () => { + it("returns property completion for entity wildcard", () => { + const verbPart = createMatchPart(["play"], "verb"); + const entityPart = createEntityPart("entity", "songName"); + const c = Construction.create( + [verbPart, entityPart], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play", defaultOptions); + expect(result).toBeDefined(); + expect(result!.properties).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("songName"); + expect(result!.closedSet).toBe(false); + expect(result!.matchedPrefixLength).toBe(4); + }); + + it("returns property completion with trailing space", () => { + const verbPart = createMatchPart(["play"], "verb"); + const entityPart = createEntityPart("entity", "songName"); + const c = Construction.create( + [verbPart, entityPart], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("songName"); + }); + + it("consumes trailing wildcard text as exact match", () => { + const verbPart = createMatchPart(["play"], "verb"); + const entityPart = createEntityPart("entity", "songName"); + const c = Construction.create( + [verbPart, entityPart], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play my song", defaultOptions); + expect(result).toBeDefined(); + // Wildcard consumes "my song" → exact match, no completions. + expect(result!.completions).toEqual([]); + expect(result!.matchedPrefixLength).toBe(12); + }); + }); + + describe("wildcard in middle of construction", () => { + // Mirrors the grammar test: + // play $(trackName:wildcard) by $(artist:wildcard) + let cache: ConstructionCache; + beforeEach(() => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createEntityPart("track", "trackName"), + createMatchPart(["by"], "prep"), + createEntityPart("artist", "artist"), + ], + new Map(), + ); + cache = makeCache([c]); + }); + + it("after prefix, returns property completion for first wildcard", () => { + const result = cache.completion("play", defaultOptions); + expect(result).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("trackName"); + expect(result!.closedSet).toBe(false); + expect(result!.matchedPrefixLength).toBe(4); + }); + + it("after prefix with space, returns property for wildcard", () => { + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("trackName"); + }); + + it("after wildcard text, returns next literal as completion", () => { + // Grammar behavior: after "play some song", the + // wildcard consumed "some song" and "by" is the next + // completion. + const result = cache.completion( + "play some song", + defaultOptions, + ); + expect(result).toBeDefined(); + expect(result!.completions).toContain("by"); + expect(result!.matchedPrefixLength).toBe(14); + }); + + it("after wildcard text and literal, returns property for second wildcard", () => { + const result = cache.completion( + "play some song by", + defaultOptions, + ); + expect(result).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("artist"); + expect(result!.closedSet).toBe(false); + }); + + it("complete input is an exact match", () => { + const result = cache.completion( + "play some song by john", + defaultOptions, + ); + expect(result).toBeDefined(); + expect(result!.completions).toEqual([]); + expect(result!.matchedPrefixLength).toBe(22); + }); + + it("multi-word wildcard text is consumed", () => { + const result = cache.completion( + "play a really long track name by", + defaultOptions, + ); + expect(result).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("artist"); + }); + }); + + describe("wildcard-enabled with matches in middle", () => { + it("advances past wildcard-enabled part when literal matches", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createWildcardEnabledPartWithMatches( + ["rock", "pop"], + "genre", + "genreName", + ), + createMatchPart(["music"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + // "rock" matches the wildcard-enabled part literally, + // so the matcher advances past it to offer "music". + const result = cache.completion("play rock ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toContain("music"); + }); + + it("offers wildcard-enabled part completions when literal doesn't match", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createWildcardEnabledPartWithMatches( + ["rock", "pop"], + "genre", + "genreName", + ), + createMatchPart(["music"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + // "play " — second part (wildcard-enabled) offers its + // literal matches and property names. + const result = cache.completion("play ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual(["pop", "rock"]); + expect(result!.closedSet).toBe(false); + }); + + it("advances past wildcard-enabled part with non-matching text to offer next literal", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createWildcardEnabledPartWithMatches( + ["rock", "pop"], + "genre", + "genreName", + ), + createMatchPart(["music"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + // "jazz" doesn't match "rock"/"pop" literally, but + // the wildcard-enabled part can consume it as wildcard + // text. The next literal "music" is offered. + const result = cache.completion("play jazz ", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toContain("music"); + }); + }); + + describe("construction starting with wildcard", () => { + it("returns property completion for leading wildcard on empty prefix", () => { + const c = Construction.create( + [ + createEntityPart("track", "trackName"), + createMatchPart(["by"], "prep"), + createEntityPart("artist", "artist"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("", defaultOptions); + expect(result).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("trackName"); + }); + + it("after wildcard text, returns next literal as completion", () => { + const c = Construction.create( + [ + createEntityPart("track", "trackName"), + createMatchPart(["by"], "prep"), + createEntityPart("artist", "artist"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("some song", defaultOptions); + expect(result).toBeDefined(); + expect(result!.completions).toContain("by"); + }); + }); + }); +}); diff --git a/ts/packages/cache/test/mergeCompletionResults.spec.ts b/ts/packages/cache/test/mergeCompletionResults.spec.ts new file mode 100644 index 000000000..f54120886 --- /dev/null +++ b/ts/packages/cache/test/mergeCompletionResults.spec.ts @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + CompletionResult, + mergeCompletionResults, +} from "../src/constructions/constructionCache.js"; + +describe("mergeCompletionResults", () => { + describe("matchedPrefixLength merging", () => { + it("returns undefined when both are undefined", () => { + const result = mergeCompletionResults(undefined, undefined); + expect(result).toBeUndefined(); + }); + + it("returns first when second is undefined", () => { + const first: CompletionResult = { + completions: ["a"], + matchedPrefixLength: 5, + }; + const result = mergeCompletionResults(first, undefined); + expect(result).toBe(first); + }); + + it("returns second when first is undefined", () => { + const second: CompletionResult = { + completions: ["b"], + matchedPrefixLength: 3, + }; + const result = mergeCompletionResults(undefined, second); + expect(result).toBe(second); + }); + + it("discards shorter-prefix completions when second is longer", () => { + const first: CompletionResult = { + completions: ["a"], + matchedPrefixLength: 5, + }; + const second: CompletionResult = { + completions: ["b"], + matchedPrefixLength: 10, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.matchedPrefixLength).toBe(10); + expect(result.completions).toEqual(["b"]); + }); + + it("discards shorter-prefix completions when first is longer", () => { + const first: CompletionResult = { + completions: ["a"], + matchedPrefixLength: 12, + }; + const second: CompletionResult = { + completions: ["b"], + matchedPrefixLength: 3, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.matchedPrefixLength).toBe(12); + expect(result.completions).toEqual(["a"]); + }); + + it("merges completions when both have equal matchedPrefixLength", () => { + const first: CompletionResult = { + completions: ["a"], + matchedPrefixLength: 5, + }; + const second: CompletionResult = { + completions: ["b"], + matchedPrefixLength: 5, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.matchedPrefixLength).toBe(5); + expect(result.completions).toEqual(["a", "b"]); + }); + + it("returns undefined matchedPrefixLength when both are missing", () => { + const first: CompletionResult = { + completions: ["a"], + }; + const second: CompletionResult = { + completions: ["b"], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.matchedPrefixLength).toBeUndefined(); + expect(result.completions).toEqual(["a", "b"]); + }); + + it("discards second when only first has matchedPrefixLength", () => { + const first: CompletionResult = { + completions: ["a"], + matchedPrefixLength: 7, + }; + const second: CompletionResult = { + completions: ["b"], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.matchedPrefixLength).toBe(7); + expect(result.completions).toEqual(["a"]); + }); + + it("discards first when only second has matchedPrefixLength", () => { + const first: CompletionResult = { + completions: [], + }; + const second: CompletionResult = { + completions: ["b"], + matchedPrefixLength: 4, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.matchedPrefixLength).toBe(4); + expect(result.completions).toEqual(["b"]); + }); + }); + + describe("completions merging", () => { + it("merges completions from both results", () => { + const first: CompletionResult = { + completions: ["a", "b"], + }; + const second: CompletionResult = { + completions: ["c", "d"], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.completions).toEqual(["a", "b", "c", "d"]); + }); + + it("handles empty completions", () => { + const first: CompletionResult = { + completions: [], + }; + const second: CompletionResult = { + completions: ["c"], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.completions).toEqual(["c"]); + }); + }); + + describe("properties merging", () => { + it("merges properties from both results", () => { + const prop1 = { + actions: [], + names: ["name1"], + }; + const prop2 = { + actions: [], + names: ["name2"], + }; + const first: CompletionResult = { + completions: [], + properties: [prop1], + }; + const second: CompletionResult = { + completions: [], + properties: [prop2], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.properties).toEqual([prop1, prop2]); + }); + + it("returns first.properties when second has none", () => { + const prop1 = { + actions: [], + names: ["name1"], + }; + const first: CompletionResult = { + completions: [], + properties: [prop1], + }; + const second: CompletionResult = { + completions: [], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.properties).toBe(first.properties); + }); + + it("returns second.properties when first has none", () => { + const prop2 = { + actions: [], + names: ["name2"], + }; + const first: CompletionResult = { + completions: [], + }; + const second: CompletionResult = { + completions: [], + properties: [prop2], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.properties).toBe(second.properties); + }); + + it("returns undefined properties when neither has them", () => { + const first: CompletionResult = { + completions: [], + }; + const second: CompletionResult = { + completions: [], + }; + const result = mergeCompletionResults(first, second)!; + expect(result.properties).toBeUndefined(); + }); + }); + + describe("separatorMode merging", () => { + it("returns undefined when neither has separatorMode", () => { + const first: CompletionResult = { completions: ["a"] }; + const second: CompletionResult = { completions: ["b"] }; + const result = mergeCompletionResults(first, second)!; + expect(result.separatorMode).toBeUndefined(); + }); + + it("returns first separatorMode when second is undefined", () => { + const first: CompletionResult = { + completions: ["a"], + separatorMode: "spacePunctuation", + }; + const second: CompletionResult = { completions: ["b"] }; + const result = mergeCompletionResults(first, second)!; + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("returns second separatorMode when first is undefined", () => { + const first: CompletionResult = { completions: ["a"] }; + const second: CompletionResult = { + completions: ["b"], + separatorMode: "spacePunctuation", + }; + const result = mergeCompletionResults(first, second)!; + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("returns most restrictive when both have separatorMode", () => { + const first: CompletionResult = { + completions: ["a"], + separatorMode: "spacePunctuation", + }; + const second: CompletionResult = { + completions: ["b"], + separatorMode: "optional", + }; + const result = mergeCompletionResults(first, second)!; + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("preserves separatorMode when first is undefined result", () => { + const second: CompletionResult = { + completions: ["b"], + separatorMode: "spacePunctuation", + }; + const result = mergeCompletionResults(undefined, second); + expect(result).toBe(second); + expect(result!.separatorMode).toBe("spacePunctuation"); + }); + }); + + describe("closedSet merging", () => { + it("returns undefined when neither has closedSet", () => { + const first: CompletionResult = { completions: ["a"] }; + const second: CompletionResult = { completions: ["b"] }; + const result = mergeCompletionResults(first, second)!; + expect(result.closedSet).toBeUndefined(); + }); + + it("returns true only when both are true", () => { + const first: CompletionResult = { + completions: ["a"], + closedSet: true, + }; + const second: CompletionResult = { + completions: ["b"], + closedSet: true, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.closedSet).toBe(true); + }); + + it("returns false when first is true and second is false", () => { + const first: CompletionResult = { + completions: ["a"], + closedSet: true, + }; + const second: CompletionResult = { + completions: ["b"], + closedSet: false, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.closedSet).toBe(false); + }); + + it("returns false when first is false and second is true", () => { + const first: CompletionResult = { + completions: ["a"], + closedSet: false, + }; + const second: CompletionResult = { + completions: ["b"], + closedSet: true, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.closedSet).toBe(false); + }); + + it("returns false when both are false", () => { + const first: CompletionResult = { + completions: ["a"], + closedSet: false, + }; + const second: CompletionResult = { + completions: ["b"], + closedSet: false, + }; + const result = mergeCompletionResults(first, second)!; + expect(result.closedSet).toBe(false); + }); + + it("returns false when only first has closedSet=true and second is undefined", () => { + const first: CompletionResult = { + completions: ["a"], + closedSet: true, + }; + const second: CompletionResult = { + completions: ["b"], + }; + const result = mergeCompletionResults(first, second)!; + // undefined treated as false → true && false = false + expect(result.closedSet).toBe(false); + }); + + it("returns false when only second has closedSet=true and first is undefined", () => { + const first: CompletionResult = { + completions: ["a"], + }; + const second: CompletionResult = { + completions: ["b"], + closedSet: true, + }; + const result = mergeCompletionResults(first, second)!; + // undefined treated as false → false && true = false + expect(result.closedSet).toBe(false); + }); + + it("preserves closedSet when first result is undefined", () => { + const second: CompletionResult = { + completions: ["b"], + closedSet: true, + }; + const result = mergeCompletionResults(undefined, second); + expect(result).toBe(second); + expect(result!.closedSet).toBe(true); + }); + + it("preserves closedSet when second result is undefined", () => { + const first: CompletionResult = { + completions: ["a"], + closedSet: false, + }; + const result = mergeCompletionResults(first, undefined); + expect(result).toBe(first); + expect(result!.closedSet).toBe(false); + }); + }); +}); diff --git a/ts/packages/chat-ui/package.json b/ts/packages/chat-ui/package.json index 3e278cf71..26b701c98 100644 --- a/ts/packages/chat-ui/package.json +++ b/ts/packages/chat-ui/package.json @@ -28,6 +28,7 @@ }, "devDependencies": { "@types/markdown-it": "^14.1.2", + "rimraf": "^6.0.1", "typescript": "~5.4.5" } } diff --git a/ts/packages/chat-ui/src/setContent.ts b/ts/packages/chat-ui/src/setContent.ts index 7198235a8..7016b3d73 100644 --- a/ts/packages/chat-ui/src/setContent.ts +++ b/ts/packages/chat-ui/src/setContent.ts @@ -8,8 +8,8 @@ import { DisplayType, DisplayMessageKind, MessageContent, - getContentForType, } from "@typeagent/agent-sdk"; +import { getContentForType } from "@typeagent/agent-sdk/helpers/display"; import DOMPurify from "dompurify"; import MarkdownIt from "markdown-it"; import { PlatformAdapter, ChatSettingsView } from "./platformAdapter.js"; diff --git a/ts/packages/cli/src/commands/interactive.ts b/ts/packages/cli/src/commands/interactive.ts index 270174b48..bf31de348 100644 --- a/ts/packages/cli/src/commands/interactive.ts +++ b/ts/packages/cli/src/commands/interactive.ts @@ -63,29 +63,12 @@ async function getCompletionsData( prefix: "", }; } - // Token-boundary logic: for non-@ input, only send complete tokens - // to the backend. The NFA can only match whole tokens, so sending a - // partial word like "p" fails. Instead, send up to the last token - // boundary and let the CLI filter locally by the partial word. - let queryLine = line; - const trimmed = line.trimStart(); - if ( - trimmed.length > 0 && - !trimmed.startsWith("@") && - !/\s$/.test(line) - ) { - const lastSpace = line.lastIndexOf(" "); - if (lastSpace === -1) { - // First word being typed: send empty to get start-state completions - queryLine = ""; - } else { - // Mid-word after spaces: send up to last token boundary - queryLine = line.substring(0, lastSpace + 1); - } - } - - const result = await dispatcher.getCommandCompletion(queryLine); - if (!result || !result.completions || result.completions.length === 0) { + // Send the full input to the backend. The grammar matcher reports + // how much of the input it consumed (matchedPrefixLength → + // startIndex), so we no longer need space-based token-boundary + // heuristics here. + const result = await dispatcher.getCommandCompletion(line); + if (result.completions.length === 0) { return null; } @@ -97,19 +80,22 @@ async function getCompletionsData( } } - // When we truncated the query, compute filter position from original input - const filterStartIndex = - queryLine !== line - ? line.lastIndexOf(" ") === -1 - ? 0 - : line.lastIndexOf(" ") + 1 - : result.startIndex; + const filterStartIndex = result.startIndex; const prefix = line.substring(0, filterStartIndex); + // When the result reports a separator-requiring mode between the + // typed prefix and the completion text, prepend a space so the + // readline display doesn't produce "playmusic" for "play" + "music". + const separator = + result.separatorMode === "space" || + result.separatorMode === "spacePunctuation" + ? " " + : ""; + return { allCompletions, filterStartIndex, - prefix, + prefix: prefix + separator, }; } catch (e) { return null; diff --git a/ts/packages/cli/src/enhancedConsole.ts b/ts/packages/cli/src/enhancedConsole.ts index 1b3030295..8f2021d4b 100644 --- a/ts/packages/cli/src/enhancedConsole.ts +++ b/ts/packages/cli/src/enhancedConsole.ts @@ -17,8 +17,8 @@ import { DisplayAppendMode, DisplayContent, MessageContent, - getContentForType, } from "@typeagent/agent-sdk"; +import { getContentForType } from "@typeagent/agent-sdk/helpers/display"; import type { RequestId, ClientIO, diff --git a/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts b/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts index ceead9816..c8625e552 100644 --- a/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts +++ b/ts/packages/defaultAgentProvider/src/mcpAgentProvider.ts @@ -12,6 +12,7 @@ import { import { AppAgentProvider } from "agent-dispatcher"; import { ArgDefinitions, + ParameterDefinitions, ParsedCommandParams, ActionContext, } from "@typeagent/agent-sdk"; @@ -109,8 +110,18 @@ function getMcpCommandHandlerTable( }, run: async ( context: ActionContext, - params: ParsedCommandParams<{}>, + params: ParsedCommandParams, ) => { + const serverArgs: string[] = []; + if (params.args) { + for (const value of Object.values(params.args)) { + if (Array.isArray(value)) { + serverArgs.push(...value.map(String)); + } else if (value !== undefined) { + serverArgs.push(String(value)); + } + } + } const instanceConfig: InstanceConfig = structuredClone( configs.getInstanceConfig(), ); @@ -118,11 +129,11 @@ function getMcpCommandHandlerTable( instanceConfig.mcpServers = {}; } instanceConfig.mcpServers[appAgentName] = { - serverScriptArgs: params.tokens, + serverScriptArgs: serverArgs, }; configs.setInstanceConfig(instanceConfig); context.actionIO.appendDisplay( - `Server arguments set to ${params.tokens.join(" ")}. Please restart TypeAgent to reflect the change.`, + `Server arguments set to ${serverArgs.join(" ")}. Please restart TypeAgent to reflect the change.`, ); }, }, diff --git a/ts/packages/dispatcher/dispatcher/src/command/command.ts b/ts/packages/dispatcher/dispatcher/src/command/command.ts index 393f3bfb3..353643231 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/command.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/command.ts @@ -73,6 +73,72 @@ export function getDefaultSubCommandDescriptor( return table.defaultSubCommand; } +// +// ── resolveCommand contract ───────────────────────────────────────────────── +// +// Given a normalized input string (output of normalizeCommand — no leading +// "@", no leading whitespace), greedily resolves as many tokens as possible +// into an agent name, then a chain of subcommand names. Returns the point +// at which resolution stopped and the state accumulated so far. +// +// Resolution is greedy and exact-match only. Each token is consumed if and +// only if it matches a key in the current command table. As soon as a +// token doesn't match, resolution stops and that token (plus everything +// after it) becomes the suffix. If a token matches a leaf descriptor +// (not a sub-table), resolution also stops — the command is fully matched. +// +// When resolution stops at a table (not a leaf), the table's +// defaultSubCommand (if any) is used as the descriptor. This is the +// "resolved to default" case — matched is false. +// +// The first token is special: it is tested as an agent name first. +// - If it matches a known, enabled agent → that agent is selected +// and the token is consumed (not included in suffix). +// - Otherwise → the "system" agent is selected and the token is +// NOT consumed (rolled back into the input for subcommand matching). +// +// Return fields (see ResolveCommandResult): +// +// parsedAppAgentName +// The agent name parsed from input, or undefined. +// Set when the first token matched an agent name, OR +// when no subcommands were consumed (commands.length===0) +// — in this case the originally-matched agent name (which +// may be undefined) is returned. +// +// actualAppAgentName +// The agent that will handle the command. Either the +// parsed agent name or "system" as fallback. +// +// commands Ordered list of subcommand names that were successfully +// consumed. May be empty. +// +// suffix The remaining input after the last consumed token, +// with leading whitespace trimmed. This is the portion +// that was NOT resolved — it may be empty (fully resolved), +// a partial word (user is still typing), or multiple +// tokens (unrecognized input). Callers use it as +// parameter text (parseCommand) or filter text for +// completions (getCommandCompletion). +// +// table The CommandDescriptorTable at the point where resolution +// stopped. Undefined when the agent has no subcommands +// (flat agent with only a root descriptor). When defined, +// table.commands lists the valid subcommands that could +// have followed. +// +// descriptor The resolved CommandDescriptor, or undefined. It is +// defined when: +// (a) an explicit subcommand matched a leaf, or +// (b) we stopped at a table that has a defaultSubCommand. +// Undefined when the table has no default and the suffix +// didn't match any subcommand. +// +// matched true only when descriptor came from an explicit +// exact-match of the last consumed token against a leaf +// in the table. false when descriptor is the default +// subcommand or when descriptor is undefined. +// export async function resolveCommand( input: string, context: CommandHandlerContext, diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index 23a2d9c94..71652aa9e 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -4,18 +4,21 @@ import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import { + CommitMode, CommandDescriptor, FlagDefinitions, ParameterDefinitions, - ParsedCommandParams, CompletionGroup, + CompletionGroups, + SeparatorMode, } from "@typeagent/agent-sdk"; import { getFlagMultiple, getFlagType, + mergeSeparatorMode, resolveFlag, } from "@typeagent/agent-sdk/helpers/command"; -import { parseParams } from "./parameters.js"; +import { parseParams, ParseParamsResult } from "./parameters.js"; import { getDefaultSubCommandDescriptor, normalizeCommand, @@ -28,58 +31,57 @@ import { CommandCompletionResult } from "@typeagent/dispatcher-types"; const debug = registerDebug("typeagent:command:completion"); const debugError = registerDebug("typeagent:command:completion:error"); -// Return the index of the first character of the last partial token. -// This is where the shell's filter text begins. -// Examples: -// "play sh" → 5 (the "s" in "sh") -// "play " → 5 (= input.length, empty filter) -// "p" → 0 -// "@calen" → 1 (right after "@") -// "@cal foo" → 5 (the "f" in "foo") -function getFilterStart(input: string) { - const commandMatch = input.match(/^\s*@/); - if (commandMatch !== null) { - const afterAt = input.substring(commandMatch.length); - if (!/\s/.test(afterAt)) { - // No space after @command — filtering command name - return commandMatch.length; - } - } - if (/\s$/.test(input)) { - // Trailing whitespace — filter is empty, starts at end - return input.length; - } - const lastSpace = input.lastIndexOf(" "); - return lastSpace === -1 ? 0 : lastSpace + 1; -} +// Detect whether the last parsed token is a recognized flag name +// awaiting a value. Pure: returns metadata only, no side effects. +// +// pendingFlag — for non-boolean flags: the canonical name +// (e.g. "--level") to pass to the agent for +// value completions. undefined for boolean +// flags (they default to true and don't pend). +// booleanFlagName — for boolean flags: the canonical display name +// (e.g. "--debug") so the caller can offer +// ["true", "false"]. undefined otherwise. +type PendingFlagInfo = { + pendingFlag: string | undefined; + booleanFlagName: string | undefined; +}; -// Return the full flag name if we are waiting a flag value. Add boolean values for completions and return undefined if the flag is boolean. -function getPendingFlag( - params: ParsedCommandParams, +function detectPendingFlag( + params: ParseParamsResult, flags: FlagDefinitions | undefined, - completions: CompletionGroup[], -) { +): PendingFlagInfo { if (params.tokens.length === 0 || flags === undefined) { - return undefined; + return { pendingFlag: undefined, booleanFlagName: undefined }; } const lastToken = params.tokens[params.tokens.length - 1]; const resolvedFlag = resolveFlag(flags, lastToken); if (resolvedFlag === undefined) { - return undefined; + return { pendingFlag: undefined, booleanFlagName: undefined }; } const type = getFlagType(resolvedFlag[1]); if (type === "boolean") { - completions.push({ - name: `--${resolvedFlag[0]}`, - completions: ["true", "false"], - }); - return undefined; // doesn't require a value. + return { + pendingFlag: undefined, + booleanFlagName: `--${resolvedFlag[0]}`, + }; } if (type === "json") { - return lastToken; + return { pendingFlag: lastToken, booleanFlagName: undefined }; } + return { + pendingFlag: `--${resolvedFlag[0]}`, + booleanFlagName: undefined, + }; +} - return `--${resolvedFlag[0]}`; // use the full flag name in case it was a short flag +// Rewind index past any trailing whitespace in `text` so it sits +// at the end of the preceding token. Returns `index` unchanged +// when the character before it is already non-whitespace. +function tokenBoundary(text: string, index: number): number { + while (index > 0 && /\s/.test(text[index - 1])) { + index--; + } + return index; } // True if surrounded by quotes at both ends (matching single or double quotes). @@ -103,6 +105,36 @@ function isFullyQuoted(value: string) { ); } +// True when the user is mid-edit on a free-form parameter value: +// - partially quoted (opening quote, no closing) +// - implicitQuotes parameter (rest-of-line) +// - bare unquoted token with no trailing space and no pending flag +function isEditingFreeFormValue( + quoted: boolean | undefined, + implicitQuotes: boolean, + hasTrailingSpace: boolean, + pendingFlag: string | undefined, +): boolean { + if (quoted === false) return true; // partially quoted + if (quoted !== undefined) return false; // fully quoted → committed + return implicitQuotes || (!hasTrailingSpace && pendingFlag === undefined); +} + +// Determine closedSet for parameter completion: +// - Agent is authoritative when invoked. +// - Free-form text with no agent → open set (anything is valid). +// - No agent and all positional args filled → only flags remain (finite set). +function computeClosedSet( + agentInvoked: boolean, + agentClosedSet: boolean | undefined, + isPartialValue: boolean, + hasRemainingArgs: boolean, +): boolean { + if (agentInvoked) return agentClosedSet ?? false; + if (isPartialValue) return false; + return !hasRemainingArgs; +} + function collectFlags( agentCommandCompletions: string[], flags: FlagDefinitions, @@ -130,80 +162,459 @@ function collectFlags( return flagCompletions; } +// Internal result from parameter-level completion. +// Mirrors CommandCompletionResult but allows commitMode to be undefined +// (the caller decides the default). +type ParameterCompletionResult = Omit & { + commitMode: CommitMode | undefined; +}; + +// ── resolveCompletionTarget ────────────────────────────────────────────── +// +// Pure decision logic: given the parse result, determine *what* to +// complete and *where* the completion attaches. No I/O, no completion +// building — the caller materialises completions from the target. +// +// Returns: +// completionNames — parameter/flag names to ask the agent about. +// startIndex — index into `input` where the completion region +// begins (before any grammar matchedPrefixLength +// override). +// tokenStartIndex — raw position of the last token's first character, +// used by the caller to apply matchedPrefixLength +// arithmetic. Equal to startIndex when no token is +// being edited (next-param / remainder modes). +// isPartialValue — true when the user is mid-edit on a free-form +// parameter value (string arg or string flag value). +// When true and no agent is invoked, closedSet is +// false because any text is valid. False for +// enumerable completions (flag names, nextArgs) +// even when startIndex points at the current token. +// includeFlags — true when the caller should add flag-name +// completions via collectFlags. +// booleanFlagName — when non-undefined, the caller should add +// ["true","false"] completions for this flag. +type CompletionTarget = { + completionNames: string[]; + startIndex: number; + tokenStartIndex: number; + isPartialValue: boolean; + includeFlags: boolean; + booleanFlagName: string | undefined; +}; + +function resolveCompletionTarget( + params: ParseParamsResult, + flags: FlagDefinitions | undefined, + input: string, + hasTrailingSpace: boolean, +): CompletionTarget { + const remainderIndex = input.length - params.remainderLength; + + // ── Pending flag detection ─────────────────────────────────────── + const { pendingFlag, booleanFlagName } = detectPendingFlag(params, flags); + + // ── Spec case 2: partial parse (remainderLength > 0) ──────────── + // Parsing stopped partway. Offer what can follow the longest + // valid prefix. + if (params.remainderLength > 0) { + const startIndex = tokenBoundary(input, remainderIndex); + return { + completionNames: [...params.nextArgs], + startIndex, + tokenStartIndex: startIndex, + isPartialValue: false, + includeFlags: true, + booleanFlagName, + }; + } + + // ── Spec case 3: full parse (remainderLength === 0) ───────────── + const { tokens, lastCompletableParam, lastParamImplicitQuotes } = params; + + // ── Spec case 3a: user is still editing the last token ────── + + // 3a-i: free-form parameter value. lastCompletableParam is set + // only for string-type params — the only type whose partial text + // is meaningful for prefix-match completion. + if (lastCompletableParam !== undefined && tokens.length > 0) { + const valueToken = tokens[tokens.length - 1]; + const quoted = isFullyQuoted(valueToken); + if ( + isEditingFreeFormValue( + quoted, + lastParamImplicitQuotes, + hasTrailingSpace, + pendingFlag, + ) + ) { + const tokenStartIndex = remainderIndex - valueToken.length; + const startIndex = tokenBoundary(input, tokenStartIndex); + return { + completionNames: [lastCompletableParam], + startIndex, + tokenStartIndex, + isPartialValue: true, + includeFlags: false, + booleanFlagName: undefined, + }; + } + } + + // 3a-ii: uncommitted flag name. A recognized flag was consumed + // but the user hasn't typed a trailing space — they might still + // change their mind (e.g. replace "--level" with "--debug"). + // Back up to the flag token's start and offer flag names. + // isPartialValue is false: flag names are an enumerable set. + if (pendingFlag !== undefined && !hasTrailingSpace) { + const flagToken = tokens[tokens.length - 1]; + const flagTokenStart = remainderIndex - flagToken.length; + const startIndex = tokenBoundary(input, flagTokenStart); + return { + completionNames: [], + startIndex, + tokenStartIndex: startIndex, + isPartialValue: false, + includeFlags: true, + booleanFlagName, + }; + } + + // ── Spec case 3b: last token committed, complete next ─────── + const startIndex = tokenBoundary(input, remainderIndex); + if (pendingFlag !== undefined && hasTrailingSpace) { + // Flag awaiting a value and the user committed with a space. + return { + completionNames: [pendingFlag], + startIndex, + tokenStartIndex: startIndex, + isPartialValue: false, + includeFlags: false, + booleanFlagName: undefined, + }; + } + return { + completionNames: [...params.nextArgs], + startIndex, + tokenStartIndex: startIndex, + isPartialValue: false, + includeFlags: true, + booleanFlagName, + }; +} + +// Complete parameter values and flags for an already-resolved command +// descriptor. Returns undefined when the descriptor declares no +// parameters (the caller decides whether sibling subcommands suffice). +// +// ── Spec ────────────────────────────────────────────────────────────────── +// +// 1. Parse parameters partially up to the longest valid index. +// +// 2. If parsing did NOT consume all input (remainderLength > 0): +// → startIndex = position after the longest valid prefix. +// → Offer completions for whatever can validly follow that prefix +// (next positional args, flag names). +// +// 3. If parsing consumed all input (remainderLength === 0): +// +// a. The user is still editing the last token — return startIndex +// at the *beginning* of that token: +// +// i. Free-form parameter value (lastCompletableParam is set): +// triggered when the token is partially quoted, uses +// implicitQuotes, or is a bare unquoted token with no +// trailing space. Completions come from the agent for +// that parameter. +// +// ii. Uncommitted flag name (pendingFlag with no trailing +// space): the flag was recognized but the user hasn't +// committed it. Offer flag names so the user can change +// their choice. +// +// b. Otherwise — the last token has been committed (trailing space +// present, or fully quoted). Return startIndex at the *end* of +// the last token (excluding trailing space) and offer completions +// for the next parameters. +// +// ── Exceptions to case 3a ──────────────────────────────────────────────── +// +// Case 3a depends on lastCompletableParam (for 3a-i) and pendingFlag +// (for 3a-ii). parseParams only sets lastCompletableParam for +// *string*-type parameters: number, boolean, and json params leave it +// undefined. This means the following scenarios fall through to 3b +// even though the user has not typed a trailing space: +// +// • A number arg without trailing space (e.g. "cmd 42") +// • A boolean arg without trailing space (e.g. "cmd true") +// • A number flag value without trailing space (e.g. "cmd --level 5") +// +// In these cases startIndex stays at the end of the last token and +// completions describe what comes *next* rather than the current +// token. This is acceptable because non-string values are not +// meaningful targets for prefix-match completion — there is no useful +// set of candidates to filter against a partial "42" or "tru". The +// caller's trie will see an empty suffix (startIndex == input.length) +// and present the next-parameter completions unfiltered, which is the +// most useful behavior for the user. +// async function getCommandParameterCompletion( descriptor: CommandDescriptor, context: CommandHandlerContext, result: ResolveCommandResult, -): Promise { - const completions: CompletionGroup[] = []; + input: string, + hasTrailingSpace: boolean, +): Promise { if (typeof descriptor.parameters !== "object") { - // No more completion, return undefined; return undefined; } - const flags = descriptor.parameters.flags; - const params = parseParams(result.suffix, descriptor.parameters, true); - const pendingFlag = getPendingFlag(params, flags, completions); - const agentCommandCompletions: string[] = []; - if (pendingFlag === undefined) { - // TODO: auto inject boolean value for boolean args. - agentCommandCompletions.push(...params.nextArgs); - if (flags !== undefined) { - const flagCompletions = collectFlags( - agentCommandCompletions, - flags, - params.flags, - ); - if (flagCompletions.length > 0) { - completions.push({ - name: "Command Flags", - completions: flagCompletions, - }); - } + const params: ParseParamsResult = parseParams( + result.suffix, + descriptor.parameters, + true, + ); + + // ── 1. Decide what to complete and where ───────────────────────── + const target = resolveCompletionTarget( + params, + descriptor.parameters.flags, + input, + hasTrailingSpace, + ); + let { startIndex } = target; + debug( + `Command completion parameter consumed length: ${params.remainderLength}`, + ); + + // ── 2. Materialise built-in completions from the target ────────── + const completions: CompletionGroup[] = []; + if (target.booleanFlagName !== undefined) { + completions.push({ + name: target.booleanFlagName, + completions: ["true", "false"], + }); + } + if (target.includeFlags && descriptor.parameters.flags !== undefined) { + const flagCompletions = collectFlags( + target.completionNames, + descriptor.parameters.flags, + params.flags, + ); + if (flagCompletions.length > 0) { + completions.push({ + name: "Command Flags", + completions: flagCompletions, + }); } - } else { - // get the potential values for the pending flag - agentCommandCompletions.push(pendingFlag); } + // ── 3. Invoke agent (if available) ─────────────────────────────── + // Note: collectFlags (above) mutates target.completionNames as a + // side effect, appending "--key." entries for JSON property flags. + // This must happen before the agent call so the agent sees the + // full list of names to complete. + let agentInvoked = false; + let agentClosedSet: boolean | undefined; + let agentCommitMode: CommitMode | undefined; + let separatorMode: SeparatorMode | undefined; + const agent = context.agents.getAppAgent(result.actualAppAgentName); - if (agent.getCommandCompletion) { - const { tokens, lastCompletableParam, lastParamImplicitQuotes } = - params; - - if (lastCompletableParam !== undefined && tokens.length > 0) { - const valueToken = tokens[tokens.length - 1]; - const quoted = isFullyQuoted(valueToken); - if ( - quoted === false || - (quoted === undefined && lastParamImplicitQuotes) - ) { - agentCommandCompletions.push(lastCompletableParam); - } - } - if (agentCommandCompletions.length > 0) { - const sessionContext = context.agents.getSessionContext( - result.actualAppAgentName, - ); - completions.push( - ...(await agent.getCommandCompletion( - result.commands, - params, - agentCommandCompletions, - sessionContext, - )), - ); + if (agent.getCommandCompletion && target.completionNames.length > 0) { + const agentName = result.actualAppAgentName; + const sessionContext = context.agents.getSessionContext(agentName); + debug( + `Command completion parameter with agent: '${agentName}' with params ${JSON.stringify(target.completionNames)}`, + ); + const agentResult: CompletionGroups = await agent.getCommandCompletion( + result.commands, + params, + target.completionNames, + sessionContext, + ); + + // Allow grammar-reported matchedPrefixLength to override + // the parse-derived startIndex. This handles CJK and other + // non-space-delimited scripts where the grammar matcher is + // the authoritative source for how far into the input it + // consumed. matchedPrefixLength is relative to the token + // content start, so add it to tokenStartIndex. + const groupPrefixLength = agentResult.matchedPrefixLength; + if (groupPrefixLength !== undefined && groupPrefixLength !== 0) { + startIndex = target.tokenStartIndex + groupPrefixLength; + completions.length = 0; // grammar overrides built-in completions } + completions.push(...agentResult.groups); + separatorMode = agentResult.separatorMode; + agentInvoked = true; + agentClosedSet = agentResult.closedSet; + agentCommitMode = agentResult.commitMode; + debug( + `Command completion parameter with agent: groupPrefixLength=${groupPrefixLength}, startIndex=${startIndex}, tokenStartIndex=${target.tokenStartIndex}`, + ); + } + + return { + completions, + startIndex, + separatorMode, + closedSet: computeClosedSet( + agentInvoked, + agentClosedSet, + target.isPartialValue, + params.nextArgs.length > 0, + ), + commitMode: agentCommitMode, + }; +} + +// Complete a resolved command descriptor: parameter completions plus +// optional sibling subcommand names from the parent table. +async function completeDescriptor( + descriptor: CommandDescriptor, + context: CommandHandlerContext, + result: ResolveCommandResult, + input: string, + hasTrailingSpace: boolean, + commandConsumedLength: number, +): Promise<{ + completions: CompletionGroup[]; + startIndex: number | undefined; + separatorMode: SeparatorMode | undefined; + closedSet: boolean; + commitMode: CommitMode | undefined; +}> { + const completions: CompletionGroup[] = []; + let separatorMode: SeparatorMode | undefined; + + const parameterCompletions = await getCommandParameterCompletion( + descriptor, + context, + result, + input, + hasTrailingSpace, + ); + + // Include sibling subcommand names when resolved to the default + // (not an explicit match), but only if parameter parsing hasn't + // consumed past the command boundary. Once the user has typed + // tokens that fill parameters (moving startIndex forward), + // they've committed to the default — subcommand names would be + // filtered against the wrong text at the wrong position. + const table = result.table; + const addSubcommands = + table !== undefined && + !result.matched && + getDefaultSubCommandDescriptor(table) === descriptor && + (parameterCompletions === undefined || + parameterCompletions.startIndex <= commandConsumedLength); + + if (addSubcommands) { + completions.push({ + name: "Subcommands", + completions: Object.keys(table!.commands), + }); + separatorMode = mergeSeparatorMode(separatorMode, "space"); + } + + if (parameterCompletions === undefined) { + return { + completions, + startIndex: undefined, + separatorMode, + closedSet: true, + commitMode: undefined, + }; } - return completions; + + completions.push(...parameterCompletions.completions); + return { + completions, + startIndex: parameterCompletions.startIndex, + separatorMode: mergeSeparatorMode( + separatorMode, + parameterCompletions.separatorMode, + ), + closedSet: parameterCompletions.closedSet, + commitMode: parameterCompletions.commitMode, + }; } +// +// ── getCommandCompletion contract ──────────────────────────────────────────── +// +// Given a partial user input string, returns the longest valid prefix, +// available completions from that point, and metadata about how they attach. +// +// Always returns a result — every input has a longest valid prefix +// (at minimum the empty string, startIndex=0). An empty completions +// array with closedSet=true means the command is fully specified and +// nothing further can follow. +// +// The portion of input after the prefix (the "suffix") is NOT a gate +// on whether completions are returned. The suffix is filter text: +// the caller feeds it to a trie to narrow the offered completions +// down to prefix-matches. Even if the suffix doesn't match anything +// today, the full set is still returned so the trie can decide. For +// example "@unknownagent " resolves only as far as "@" (startIndex=1); +// completions offer all agent names and system subcommands, and the +// trie filters "unknownagent " against them (yielding no matches, but +// that is the trie's job, not ours). +// +// Return fields (see CommandCompletionResult): +// +// startIndex Length of the longest resolved prefix. +// input[0..startIndex) is the "anchor" — the text +// that was fully consumed by normalizeCommand → +// resolveCommand (→ parseParams). Completions +// describe what can validly follow after the anchor. +// May be overridden by a grammar-reported matchedPrefixLength +// from a CompletionGroups result. +// +// startIndex is always placed at a token boundary +// (not on separator whitespace). Each production +// site — resolveCommand consumed length, parseParams +// remainder, and the lastCompletableParam adjustment +// — applies tokenBoundary() to enforce this. +// Consumers treat the text after the anchor as +// "rawPrefix", expect it to begin with a separator +// (per separatorMode, which defaults to "space" +// when omitted), and strip the separator before +// filtering. Keeping whitespace inside the anchor +// would violate this contract. +// The grammar-reported matchedPrefixLength override (Site 4) +// is added to the token start position (before the +// separator space), not to tokenBoundary — the grammar +// reports how many characters of the token content it +// consumed, which is relative to the token start. +// +// completions Array of CompletionGroup items from up to three sources: +// (a) built-in command / subcommand / agent-name lists, +// (b) flag names from the descriptor's ParameterDefinitions, +// (c) agent-provided groups via the agent's +// getCommandCompletion callback. +// +// separatorMode +// Describes what kind of separator is required between +// the matched prefix and the completion text. +// Merged: most restrictive mode from any source wins. +// When omitted, consumers default to "space". +// +// closedSet true when the returned completions form a *closed set* +// of valid continuations after the prefix. When true +// and the user types something that doesn't prefix-match +// any completion, the caller can skip re-fetching because +// no other valid input exists. Subcommand and agent-name +// lists are always closed sets. Parameter completions are +// closed only when no agent was invoked and no +// free-form positional args remain unfilled — see +// ParameterCompletionResult for the heuristic. +// export async function getCommandCompletion( input: string, context: CommandHandlerContext, -): Promise { +): Promise { try { debug(`Command completion start: '${input}'`); - const filterStart = getFilterStart(input); // Always send the full input so the backend sees all typed text. const partialCommand = normalizeCommand(input, context); @@ -212,82 +623,147 @@ export async function getCommandCompletion( const result = await resolveCommand(partialCommand, context); const table = result.table; - if (table === undefined) { - // Unknown app agent, or appAgent doesn't support commands - // Return undefined to indicate no more completions for this prefix. - return undefined; - } - // Collect completions + // The parse-derived startIndex: command resolution consumed + // everything up to the suffix; within the suffix, parameter + // parsing determines the last incomplete token position. + const commandConsumedLength = input.length - result.suffix.length; + debug( + `Command completion command consumed length: ${commandConsumedLength}, suffix: '${result.suffix}'`, + ); + let startIndex = tokenBoundary(input, commandConsumedLength); + const hasTrailingSpace = /\s$/.test(partialCommand); + + // Collect completions and track separatorMode across all sources. const completions: CompletionGroup[] = []; - if (input.trim() === "") { - completions.push({ - name: "Command Prefixes", - completions: ["@"], - }); - } + let commitMode: "explicit" | "eager" = "explicit"; + let separatorMode: SeparatorMode | undefined; + let closedSet = true; const descriptor = result.descriptor; - if (descriptor !== undefined) { - if ( - result.suffix.length === 0 && - table !== undefined && - getDefaultSubCommandDescriptor(table) === result.descriptor - ) { - // Match the default sub command. Includes additional subcommand names - completions.push({ - name: "Subcommands", - completions: Object.keys(table.commands), - }); - } - const parameterCompletions = await getCommandParameterCompletion( + + // When the last command token was exactly matched but the + // user hasn't typed a trailing space, they haven't committed + // it yet. Offer subcommand alternatives at that token's + // position instead of jumping to parameter completions. + const uncommittedCommand = + descriptor !== undefined && + result.matched && + !hasTrailingSpace && + result.suffix === "" && + table !== undefined; + + if (uncommittedCommand) { + const lastCmd = result.commands[result.commands.length - 1]; + startIndex = + tokenBoundary(input, commandConsumedLength) - lastCmd.length; + completions.push({ + name: "Subcommands", + completions: Object.keys(table!.commands), + }); + separatorMode = mergeSeparatorMode(separatorMode, "none"); + // closedSet stays true: subcommand names are exhaustive. + } else if (descriptor !== undefined) { + const desc = await completeDescriptor( descriptor, context, result, + input, + hasTrailingSpace, + commandConsumedLength, ); - if (parameterCompletions === undefined) { - if (completions.length === 0) { - // No more completion, return undefined; - return undefined; - } - } else { - completions.push(...parameterCompletions); + completions.push(...desc.completions); + if (desc.startIndex !== undefined) { + startIndex = desc.startIndex; } - } else { - if (result.suffix.length !== 0) { - // Unknown command - // Return undefined to indicate no more completions for this prefix. - return undefined; + separatorMode = mergeSeparatorMode( + separatorMode, + desc.separatorMode, + ); + closedSet = desc.closedSet; + if (desc.commitMode === "eager") { + commitMode = "eager"; } + } else if (table !== undefined) { + // descriptor is undefined: the suffix didn't resolve to any + // known command or subcommand. startIndex already points to + // where resolution stopped (the start of the suffix), so we + // offer every valid continuation from that point — subcommand + // names from the current table. Agent names are handled + // independently below. The suffix is filter text for the + // caller's trie, not a reason to suppress completions. + // Examples: + // "@com" → suffix="com", completions include + // subcommands + agent names (trie + // narrows to "comptest", etc.) + // "@unknownagent " → suffix="unknownagent ", same set + // (trie finds no match — that's fine) completions.push({ name: "Subcommands", completions: Object.keys(table.commands), }); - if ( - result.parsedAppAgentName === undefined && - result.commands.length === 0 - ) { - // Include the agent names - completions.push({ - name: "Agent Names", - completions: context.agents - .getAppAgentNames() - .filter((name) => - context.agents.isCommandEnabled(name), - ), - }); - } + separatorMode = mergeSeparatorMode( + separatorMode, + result.parsedAppAgentName !== undefined || + result.commands.length > 0 + ? "space" + : "optional", + ); + } else { + // Both table and descriptor are undefined — the agent + // returned no commands at all. Nothing to add; + // completions stays empty, closedSet stays true. } - const completionResult = { - startIndex: filterStart, + // Independently of which branch above ran, offer agent names + // when the user hasn't typed a recognized agent and hasn't + // navigated into a subcommand tree. This is decoupled from + // the three-way branch so it works regardless of whether the + // fallback agent has a command table. + if ( + result.parsedAppAgentName === undefined && + result.commands.length === 0 + ) { + completions.push({ + name: "Agent Names", + completions: context.agents + .getAppAgentNames() + .filter((name) => context.agents.isCommandEnabled(name)), + }); + separatorMode = mergeSeparatorMode(separatorMode, "optional"); + } + + if (startIndex === 0) { + // It is the first token, add "@" for the command prefix + completions.push({ + name: "Command Prefixes", + completions: ["@"], + }); + + // The first token doesn't require separator before it (separatorMode to optional) + // and it doesn't require space after it (commitMode to eager) + separatorMode = "optional"; + commitMode = "eager"; + } + const completionResult: CommandCompletionResult = { + startIndex, completions, + separatorMode, + closedSet, + commitMode, }; debug(`Command completion result:`, completionResult); return completionResult; } catch (e: any) { debugError(`Command completion error: ${e}\n${e.stack}`); - return undefined; + // On error, return a safe default — don't claim closedSet + // since we don't know what went wrong. + return { + startIndex: 0, + completions: [], + separatorMode: undefined, + closedSet: false, + }; } } diff --git a/ts/packages/dispatcher/dispatcher/src/command/parameters.ts b/ts/packages/dispatcher/dispatcher/src/command/parameters.ts index 9fae97e38..266cc3f81 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/parameters.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/parameters.ts @@ -136,11 +136,44 @@ function parseValueToken( } } +// Extension of ParsedCommandParams that also reports how much of the +// parameter string was *not* consumed by the parser. Used internally by +// the completion layer to compute startIndex from parse position rather +// than by reverse-engineering filter text from token lengths. +// +// NOTE: remainderLength measures only the unconsumed *token* text — it +// does NOT include any inter-token whitespace that the tokenizer +// stripped between the last consumed token and the unconsumed portion. +// For example, parsing "hello extra" with a single-arg definition +// yields remainderLength = 5 ("extra"), not 6 (" extra"). The +// completion layer in completion.ts accounts for this by backing +// startIndex over any preceding whitespace after the initial +// computation. +export type ParseParamsResult = + ParsedCommandParams & { + // Information for partial command completion. + tokens: string[]; // The list of tokens parsed from the command. + lastCompletableParam: string | undefined; // The last parameter that was parsed that can be completed. + lastParamImplicitQuotes: boolean; // If the last parameter is implicitly quoted. + nextArgs: string[]; // A list of potential arguments next. + + /** Length of the (trimmed) parameter text left unconsumed. + * Excludes inter-token whitespace between the last consumed + * token and the start of the unconsumed remainder. */ + remainderLength: number; + }; + +// Tokenizes and parses a parameter string into typed flags and positional +// arguments according to the given parameter definitions. Supports quoted +// values, boolean/number/string/json types, multi-value flags and arguments, +// implicit-quote (rest-of-line) arguments, and a `--` separator. When +// `partial` is true, parsing errors are silently ignored so the function can +// be used for live completions on incomplete input. export function parseParams( parameters: string, paramDefs: T, partial: boolean = false, -): ParsedCommandParams { +): ParseParamsResult { // Use trimStart (not trim) so trailing whitespace is preserved for // completion: a trailing space signals that the last token is complete. let curr = partial ? parameters.trimStart() : parameters.trim(); @@ -192,6 +225,11 @@ export function parseParams( let lastCompletableParam: string | undefined = undefined; let lastParamImplicitQuotes: boolean = false; let advanceMultiple = false; + // Track how far the parser successfully consumed. Updated after + // each fully-resolved entity (flag name, flag+value, argument). + // On error in partial mode the value stays at the last-good + // position, giving the completion layer an accurate remainder. + let lastGoodCurrLength = curr.length; while (true) { try { // Save the rest for implicit quote arguments; @@ -210,6 +248,7 @@ export function parseParams( } const [name, flag] = flagInfo; const valueType = getFlagType(flag); + lastGoodCurrLength = curr.length; // flag name consumed let value: FlagValueTypes; const rollback = curr; const valueToken = nextToken(); @@ -250,6 +289,7 @@ export function parseParams( } parsedFlags[name] = value; } + lastGoodCurrLength = curr.length; // flag+value consumed if (valueType === "string") { lastCompletableParam = `--${name}`; lastParamImplicitQuotes = false; @@ -307,6 +347,7 @@ export function parseParams( parsedArgs[name].push(argValue); } } + lastGoodCurrLength = curr.length; // argument consumed if (argType === "string") { lastCompletableParam = name; lastParamImplicitQuotes = argDef.implicitQuotes ?? false; @@ -367,5 +408,6 @@ export function parseParams( lastCompletableParam, lastParamImplicitQuotes, nextArgs, + remainderLength: lastGoodCurrLength, }; } diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts index 05855042f..238458429 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts @@ -4,7 +4,7 @@ import { CommandHandlerContext } from "../../commandHandlerContext.js"; import { ActionContext, - CompletionGroup, + CompletionGroups, ParsedCommandParams, SessionContext, } from "@typeagent/agent-sdk"; @@ -51,19 +51,22 @@ export class MatchCommandHandler implements CommandHandler { context: SessionContext, params: ParsedCommandParams, names: string[], - ): Promise { - const completions: CompletionGroup[] = []; + ): Promise { + const result: CompletionGroups = { groups: [] }; for (const name of names) { if (name === "request") { - const requestPrefix = params.args.request; - completions.push( - ...(await requestCompletion( - requestPrefix, - context.agentContext, - )), + const requestPrefix = params.args.request ?? ""; + const requestResult = await requestCompletion( + requestPrefix, + context.agentContext, ); + result.groups.push(...requestResult.groups); + result.matchedPrefixLength = requestResult.matchedPrefixLength; + result.separatorMode = requestResult.separatorMode; + result.closedSet = requestResult.closedSet; + result.commitMode = requestResult.commitMode; } } - return completions; + return result; } } diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts index 82c8b0656..cfe2a3adb 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts @@ -35,7 +35,7 @@ import { ActionContext, ParsedCommandParams, SessionContext, - CompletionGroup, + CompletionGroups, } from "@typeagent/agent-sdk"; import { CommandHandler } from "@typeagent/agent-sdk/helpers/command"; import { @@ -474,19 +474,22 @@ export class RequestCommandHandler implements CommandHandler { context: SessionContext, params: ParsedCommandParams, names: string[], - ): Promise { - const completions: CompletionGroup[] = []; + ): Promise { + const result: CompletionGroups = { groups: [] }; for (const name of names) { if (name === "request") { - const requestPrefix = params.args.request; - completions.push( - ...(await requestCompletion( - requestPrefix, - context.agentContext, - )), + const requestPrefix = params.args.request ?? ""; + const requestResult = await requestCompletion( + requestPrefix, + context.agentContext, ); + result.groups.push(...requestResult.groups); + result.matchedPrefixLength = requestResult.matchedPrefixLength; + result.separatorMode = requestResult.separatorMode; + result.closedSet = requestResult.closedSet; + result.commitMode = requestResult.commitMode; } } - return completions; + return result; } } diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts index 12362c3ac..807b71612 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts @@ -5,7 +5,7 @@ import { CommandHandlerContext } from "../../commandHandlerContext.js"; import { openai as ai } from "aiclient"; import { ActionContext, - CompletionGroup, + CompletionGroups, ParsedCommandParams, SessionContext, } from "@typeagent/agent-sdk"; @@ -76,19 +76,22 @@ export class TranslateCommandHandler implements CommandHandler { context: SessionContext, params: ParsedCommandParams, names: string[], - ): Promise { - const completions: CompletionGroup[] = []; + ): Promise { + const result: CompletionGroups = { groups: [] }; for (const name of names) { if (name === "request") { - const requestPrefix = params.args.request; - completions.push( - ...(await requestCompletion( - requestPrefix, - context.agentContext, - )), + const requestPrefix = params.args.request ?? ""; + const requestResult = await requestCompletion( + requestPrefix, + context.agentContext, ); + result.groups.push(...requestResult.groups); + result.matchedPrefixLength = requestResult.matchedPrefixLength; + result.separatorMode = requestResult.separatorMode; + result.closedSet = requestResult.closedSet; + result.commitMode = requestResult.commitMode; } } - return completions; + return result; } } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts index b3591b0c7..f5dd26900 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/actionCommandHandler.ts @@ -5,6 +5,7 @@ import { ActionContext, AppAction, CompletionGroup, + CompletionGroups, ParsedCommandParams, PartialParsedCommandParams, SessionContext, @@ -343,7 +344,7 @@ export class ActionCommandHandler implements CommandHandler { context: SessionContext, params: PartialParsedCommandParams, names: string[], - ): Promise { + ): Promise { const systemContext = context.agentContext; const completions: CompletionGroup[] = []; for (const name of names) { @@ -430,6 +431,6 @@ export class ActionCommandHandler implements CommandHandler { continue; } } - return completions; + return { groups: completions }; } } diff --git a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts index 117527144..2f3ad2282 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/system/handlers/configCommandHandlers.ts @@ -18,6 +18,7 @@ import chalk from "chalk"; import { ActionContext, CompletionGroup, + CompletionGroups, ParameterDefinitions, ParsedCommandParams, PartialParsedCommandParams, @@ -502,7 +503,7 @@ class AgentToggleCommandHandler implements CommandHandler { } } - return completions; + return { groups: completions }; } } @@ -557,7 +558,7 @@ class ExplainerCommandHandler implements CommandHandler { }); } } - return completions; + return { groups: completions }; } } @@ -626,7 +627,7 @@ class ConfigModelSetCommandHandler implements CommandHandler { context: SessionContext, params: PartialParsedCommandParams, names: string[], - ): Promise { + ): Promise { const completions: CompletionGroup[] = []; for (const name of names) { if (name === "model") { @@ -637,7 +638,7 @@ class ConfigModelSetCommandHandler implements CommandHandler { } } - return completions; + return { groups: completions }; } } @@ -746,7 +747,7 @@ class FixedSchemaCommandHandler implements CommandHandler { context: SessionContext, params: PartialParsedCommandParams, names: string[], - ): Promise { + ): Promise { const completions: CompletionGroup[] = []; const systemContext = context.agentContext; for (const name of names) { @@ -757,7 +758,7 @@ class FixedSchemaCommandHandler implements CommandHandler { }); } } - return completions; + return { groups: completions }; } } @@ -839,7 +840,7 @@ class GrammarSystemCommandHandler implements CommandHandler { }); } } - return completions; + return { groups: completions }; } } class GrammarUseDFACommandHandler implements CommandHandler { @@ -882,7 +883,7 @@ class GrammarUseDFACommandHandler implements CommandHandler { completions.push({ name, completions: ["true", "false"] }); } } - return completions; + return { groups: completions }; } } @@ -1219,7 +1220,7 @@ class ConfigRequestCommandHandler implements CommandHandler { context: SessionContext, params: PartialParsedCommandParams, names: string[], - ): Promise { + ): Promise { const completions: CompletionGroup[] = []; const systemContext = context.agentContext; for (const name of names) { @@ -1244,7 +1245,7 @@ class ConfigRequestCommandHandler implements CommandHandler { } } } - return completions; + return { groups: completions }; } } diff --git a/ts/packages/dispatcher/dispatcher/src/helpers/console.ts b/ts/packages/dispatcher/dispatcher/src/helpers/console.ts index 8316089df..c69dc0b9d 100644 --- a/ts/packages/dispatcher/dispatcher/src/helpers/console.ts +++ b/ts/packages/dispatcher/dispatcher/src/helpers/console.ts @@ -6,8 +6,8 @@ import { DisplayAppendMode, DisplayContent, MessageContent, - getContentForType, } from "@typeagent/agent-sdk"; +import { getContentForType } from "@typeagent/agent-sdk/helpers/display"; import type { RequestId, ClientIO, diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index 5172d4a34..902243497 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -4,7 +4,11 @@ import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import registerDebug from "debug"; import { ExecutableAction, getPropertyInfo, MatchOptions } from "agent-cache"; -import { CompletionGroup, TypeAgentAction } from "@typeagent/agent-sdk"; +import { + CompletionGroup, + CompletionGroups, + TypeAgentAction, +} from "@typeagent/agent-sdk"; import { DeepPartialUndefined } from "@typeagent/common-utils"; import { ActionParamType, @@ -71,9 +75,9 @@ function getCompletionNamespaceKeys(context: CommandHandlerContext): string[] { } export async function requestCompletion( - requestPrefix: string | undefined, + requestPrefix: string, context: CommandHandlerContext, -): Promise { +): Promise { debugCompletion(`Request completion for prefix: '${requestPrefix}'`); const namespaceKeys = getCompletionNamespaceKeys(context); debugCompletion(`Request completion namespace keys`, namespaceKeys); @@ -88,9 +92,12 @@ export async function requestCompletion( const results = context.agentCache.completion(requestPrefix, options); if (results === undefined) { - return []; + return { groups: [] }; } + const matchedPrefixLength = results.matchedPrefixLength; + const separatorMode = results.separatorMode; + const closedSet = results.closedSet; const completions: CompletionGroup[] = []; if (results.completions.length > 0) { completions.push({ @@ -102,7 +109,17 @@ export async function requestCompletion( } if (results.properties === undefined) { - return completions; + return { + groups: completions, + matchedPrefixLength, + separatorMode, + closedSet, + // Grammar completions use eager commit: tokens can abut + // without an explicit delimiter (e.g. CJK characters), + // so the session should re-fetch immediately when a + // completion is uniquely satisfied. + commitMode: "eager", + }; } const propertyCompletions = new Map(); @@ -122,7 +139,14 @@ export async function requestCompletion( } completions.push(...propertyCompletions.values()); - return completions; + return { + groups: completions, + matchedPrefixLength, + separatorMode, + closedSet, + // Grammar completions use eager commit (see note above). + commitMode: "eager", + }; } async function collectActionCompletions( diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts new file mode 100644 index 000000000..a21a8d0d7 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -0,0 +1,1411 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + AppAgent, + AppAgentManifest, + CompletionGroups, +} from "@typeagent/agent-sdk"; +import { AppAgentProvider } from "../src/agentProvider/agentProvider.js"; +import { + type CommandHandlerContext, + closeCommandHandlerContext, + initializeCommandHandlerContext, +} from "../src/context/commandHandlerContext.js"; +import { getCommandInterface } from "@typeagent/agent-sdk/helpers/command"; +import { getCommandCompletion } from "../src/command/completion.js"; + +// --------------------------------------------------------------------------- +// Test agent with parameters for completion testing +// --------------------------------------------------------------------------- + +// Shared grammar completion mock. Simulates a grammar that recognises +// CJK ("東京" → "タワー"/"駅") and English ("Tokyo" → "Tower"/"Station") +// prefixes. `token` is the raw last token from parseParams — it may +// include a leading quote for open-quoted input. +function grammarCompletion(token: string): CompletionGroups { + // Strip a leading quote so grammar match logic operates on text only. + const text = token.startsWith('"') ? token.substring(1) : token; + const quoteOffset = token.length - text.length; // 0 or 1 + + if (text.startsWith("Tokyo")) { + const suffix = text.substring(5).trim(); + if (suffix.startsWith("Tower") || suffix.startsWith("Station")) { + return { groups: [] }; // completed match + } + return { + groups: [ + { + name: "Grammar", + completions: ["Tower", "Station"], + }, + ], + matchedPrefixLength: quoteOffset + 5, + separatorMode: "space", + }; + } + if (text.startsWith("東京")) { + const suffix = text.substring(2); + if (suffix.startsWith("タワー") || suffix.startsWith("駅")) { + return { groups: [] }; // completed match + } + return { + groups: [ + { + name: "Grammar", + completions: ["タワー", "駅"], + }, + ], + matchedPrefixLength: quoteOffset + 2, + separatorMode: "optional", + }; + } + // No prefix matched — offer initial completions. + return { + groups: [ + { + name: "Grammar", + completions: ["Tokyo ", "東京"], + }, + ], + ...(token.length > 0 ? { matchedPrefixLength: 0 } : {}), + separatorMode: "space", + }; +} + +const handlers = { + description: "Completion test agent", + defaultSubCommand: "run", + commands: { + run: { + description: "Run a task", + parameters: { + args: { + task: { + description: "Task name", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("task")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Tasks", + completions: ["build", "test", "deploy"], + }, + ], + }; + }, + }, + nested: { + description: "Nested test", + commands: { + sub: { + description: "Nested sub", + parameters: { + args: { + value: { + description: "A value", + }, + }, + flags: { + verbose: { + description: "Enable verbose", + type: "boolean" as const, + char: "v", + }, + }, + }, + run: async () => {}, + }, + }, + }, + noop: { + description: "No-params command", + run: async () => {}, + }, + flagsonly: { + description: "Flags-only command", + parameters: { + flags: { + debug: { + description: "Enable debug", + type: "boolean" as const, + }, + level: { + description: "Log level", + type: "number" as const, + }, + }, + }, + run: async () => {}, + }, + twoarg: { + description: "Two-arg command", + parameters: { + args: { + first: { + description: "First arg", + }, + second: { + description: "Second arg", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("first") && !names.includes("second")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Values", + completions: ["alpha", "beta"], + }, + ], + }; + }, + }, + search: { + description: "Implicit-quotes command", + parameters: { + args: { + query: { + description: "Search query", + implicitQuotes: true, + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("query")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Suggestions", + completions: ["hello world", "foo bar"], + }, + ], + }; + }, + }, + grammar: { + description: "Grammar matchedPrefixLength command", + parameters: { + args: { + phrase: { + description: "A CJK phrase", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + params: unknown, + names: string[], + ): Promise => { + if (!names.includes("phrase")) { + return { groups: [] }; + } + const p = params as { tokens?: string[] }; + const lastToken = p.tokens?.[p.tokens.length - 1] ?? ""; + return grammarCompletion(lastToken); + }, + }, + grammariq: { + description: "Grammar with implicitQuotes", + parameters: { + args: { + query: { + description: "CJK search query", + implicitQuotes: true, + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + params: unknown, + names: string[], + ): Promise => { + if (!names.includes("query")) { + return { groups: [] }; + } + const p = params as { tokens?: string[] }; + const lastToken = p.tokens?.[p.tokens.length - 1] ?? ""; + return grammarCompletion(lastToken); + }, + }, + exhaustive: { + description: "Agent returns closedSet=true", + parameters: { + args: { + color: { + description: "A color", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("color")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Colors", + completions: ["red", "green", "blue"], + }, + ], + closedSet: true, + }; + }, + }, + nonexhaustive: { + description: "Agent returns closedSet=false", + parameters: { + args: { + item: { + description: "An item", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("item")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Items", + completions: ["apple", "banana"], + }, + ], + closedSet: false, + }; + }, + }, + nocompletefield: { + description: "Agent does not set closedSet", + parameters: { + args: { + thing: { + description: "A thing", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("thing")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Things", + completions: ["widget", "gadget"], + }, + ], + }; + }, + }, + }, +} as const; + +const config: AppAgentManifest = { + emojiChar: "🧪", + description: "Completion test", +}; + +const agent: AppAgent = { + ...getCommandInterface(handlers), +}; + +// --------------------------------------------------------------------------- +// Flat agent — returns a single CommandDescriptor (no subcommand table) +// --------------------------------------------------------------------------- +const flatHandlers = { + description: "Flat agent with params but no subcommands", + parameters: { + args: { + target: { + description: "Build target", + }, + }, + flags: { + release: { + description: "Release build", + type: "boolean" as const, + }, + }, + }, + run: async () => {}, +} as const; + +const flatConfig: AppAgentManifest = { + emojiChar: "📦", + description: "Flat completion test", +}; + +const flatAgent: AppAgent = { + ...getCommandInterface(flatHandlers), +}; + +// --------------------------------------------------------------------------- +// No-commands agent — getCommands returns undefined +// --------------------------------------------------------------------------- +const noCommandsConfig: AppAgentManifest = { + emojiChar: "🚫", + description: "Agent with no commands", +}; + +const noCommandsAgent: AppAgent = { + // getCommands not defined → resolveCommand sees descriptors=undefined +}; + +// --------------------------------------------------------------------------- +// numstr agent — number arg followed by string arg (with getCompletion) +// --------------------------------------------------------------------------- +const numstrHandlers = { + description: "Agent with number then string arg", + defaultSubCommand: "numstr", + commands: { + numstr: { + description: "Number then string command", + parameters: { + args: { + count: { + description: "A count", + type: "number" as const, + }, + name: { + description: "A name", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("name")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Names", + completions: ["alice", "bob"], + }, + ], + }; + }, + }, + }, +} as const; + +const numstrConfig: AppAgentManifest = { + emojiChar: "🔢", + description: "Numstr completion test", +}; + +const numstrAgent: AppAgent = { + ...getCommandInterface(numstrHandlers), +}; + +const testCompletionAgentProviderMulti: AppAgentProvider = { + getAppAgentNames: () => ["comptest", "flattest", "nocmdtest", "numstrtest"], + getAppAgentManifest: async (name: string) => { + if (name === "comptest") return config; + if (name === "flattest") return flatConfig; + if (name === "nocmdtest") return noCommandsConfig; + if (name === "numstrtest") return numstrConfig; + throw new Error(`Unknown: ${name}`); + }, + loadAppAgent: async (name: string) => { + if (name === "comptest") return agent; + if (name === "flattest") return flatAgent; + if (name === "nocmdtest") return noCommandsAgent; + if (name === "numstrtest") return numstrAgent; + throw new Error(`Unknown: ${name}`); + }, + unloadAppAgent: async (name: string) => { + if (!["comptest", "flattest", "nocmdtest", "numstrtest"].includes(name)) + throw new Error(`Unknown: ${name}`); + }, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe("Command Completion - startIndex", () => { + let context: CommandHandlerContext; + + beforeAll(async () => { + context = await initializeCommandHandlerContext("test", { + agents: { + actions: false, + schemas: false, + }, + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + appAgentProviders: [testCompletionAgentProviderMulti], + }); + }); + afterAll(async () => { + if (context) { + await closeCommandHandlerContext(context); + } + }); + + describe("agent + subcommand resolution", () => { + it("returns startIndex at suffix boundary for '@comptest run '", async () => { + const result = await getCommandCompletion( + "@comptest run ", + context, + ); + expect(result).toBeDefined(); + // "@comptest run " → suffix is "" after command resolution, + // "run" is explicitly matched so no Subcommands group. + // parameter parsing has no tokens so + // startIndex = inputLength - 0 = 14, then unconditional + // whitespace backing rewinds over trailing space → 13. + expect(result!.startIndex).toBe(13); + // Agent getCompletion is invoked for the "task" arg → + // completions are not exhaustive. + expect(result!.closedSet).toBe(false); + }); + + it("returns startIndex accounting for partial param for '@comptest run bu'", async () => { + const result = await getCommandCompletion( + "@comptest run bu", + context, + ); + expect(result).toBeDefined(); + // "@comptest run bu" (16 chars) + // No trailing space → hasTrailingSpace=false. + // suffix is "bu", parameter parsing fully consumes "bu". + // lastCompletableParam="task", bare unquoted token, + // !hasTrailingSpace → exclusive path fires: backs up + // startIndex to the start of "bu" → 13. + expect(result!.startIndex).toBe(13); + // Agent IS invoked ("task" in agentCommandCompletions). + // Agent does not set closedSet → defaults to false. + expect(result!.closedSet).toBe(false); + }); + + it("returns startIndex for nested command '@comptest nested sub '", async () => { + const result = await getCommandCompletion( + "@comptest nested sub ", + context, + ); + expect(result).toBeDefined(); + // "@comptest nested sub " (21 chars) + // suffix is "" after command resolution; + // parameter parsing has no tokens; startIndex = 21 - 0 = 21, + // then unconditional whitespace backing → 20. + expect(result!.startIndex).toBe(20); + // Unfilled "value" arg (free-form) → not exhaustive. + expect(result!.closedSet).toBe(false); + }); + + it("returns startIndex for partial flag '@comptest nested sub --ver'", async () => { + const result = await getCommandCompletion( + "@comptest nested sub --ver", + context, + ); + expect(result).toBeDefined(); + // "@comptest nested sub --ver" (26 chars) + // suffix is "--ver", parameter parsing sees token "--ver" (5 chars) + // startIndex = 26 - 5 = 21, then unconditional whitespace + // backing rewinds over the space before "--ver" → 20. + expect(result!.startIndex).toBe(20); + // Unfilled "value" arg → not exhaustive. + expect(result!.closedSet).toBe(false); + }); + }); + + describe("empty and minimal input", () => { + it("returns completions for empty input", async () => { + const result = await getCommandCompletion("", context); + expect(result).toBeDefined(); + expect(result!.completions.length).toBeGreaterThan(0); + // completions should include "@" + const prefixes = result!.completions.find( + (g) => g.name === "Command Prefixes", + ); + expect(prefixes).toBeDefined(); + expect(prefixes!.completions).toContain("@"); + // Empty input normalizes to "{requestHandler} request" which + // has open parameters → not exhaustive. + expect(result!.closedSet).toBe(false); + }); + + it("returns startIndex 0 for empty input", async () => { + const result = await getCommandCompletion("", context); + expect(result).toBeDefined(); + expect(result!.startIndex).toBe(0); + }); + + it("returns startIndex at end for whitespace-only input", async () => { + const result = await getCommandCompletion(" ", context); + expect(result).toBeDefined(); + // " " normalizes to a command prefix with no suffix; + // startIndex = input.length - suffix.length = 2, then + // unconditional whitespace backing rewinds to 0. + expect(result!.startIndex).toBe(0); + }); + }); + + describe("agent name level", () => { + it("returns subcommands at agent boundary '@comptest '", async () => { + const result = await getCommandCompletion("@comptest ", context); + expect(result).toBeDefined(); + const subcommands = result!.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeDefined(); + expect(subcommands!.completions).toContain("run"); + expect(subcommands!.completions).toContain("nested"); + // Default subcommand "run" has agent completions → not exhaustive. + expect(result!.closedSet).toBe(false); + // Agent was recognized → no agent names offered. + const agentGroup = result!.completions.find( + (g) => g.name === "Agent Names", + ); + expect(agentGroup).toBeUndefined(); + }); + + it("returns matching agent names for partial prefix '@com'", async () => { + const result = await getCommandCompletion("@com", context); + // "@com" → normalizeCommand strips '@' → "com" + // resolveCommand: "com" isn't an agent name → system agent, + // system has no defaultSubCommand → descriptor=undefined, + // suffix="com". Completions include both system subcommands + // and agent names; the trie filters "com" against them. + expect(result.startIndex).toBe(1); + const agentGroup = result.completions.find( + (g) => g.name === "Agent Names", + ); + expect(agentGroup).toBeDefined(); + expect(agentGroup!.completions).toContain("comptest"); + const subcommandGroup = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommandGroup).toBeDefined(); + expect(result.closedSet).toBe(true); + }); + + it("returns completions for unknown agent with startIndex at '@'", async () => { + const result = await getCommandCompletion( + "@unknownagent ", + context, + ); + // "@unknownagent " → longest valid prefix is "@" + // (startIndex = 1). Completions offer system subcommands + // and agent names so the user can correct the typo. + expect(result.startIndex).toBe(1); + const agentGroup = result.completions.find( + (g) => g.name === "Agent Names", + ); + expect(agentGroup).toBeDefined(); + const subcommandGroup = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommandGroup).toBeDefined(); + expect(result.closedSet).toBe(true); + }); + }); + + describe("startIndex tracks last token position", () => { + it("startIndex at token boundary with trailing space", async () => { + const result = await getCommandCompletion( + "@comptest run build ", + context, + ); + // "@comptest run build " (20 chars) + // suffix is "build ", token "build" is fully consumed, + // remainderLength = 0 → startIndex = 20, then unconditional + // whitespace backing rewinds over trailing space → 19. + expect(result).toBeDefined(); + expect(result!.startIndex).toBe(19); + // All positional args filled ("task" consumed "build"), + // no flags, agent not invoked (agentCommandCompletions + // is empty) → exhaustive. + expect(result!.closedSet).toBe(true); + }); + + it("startIndex backs over whitespace before unconsumed remainder", async () => { + const result = await getCommandCompletion( + "@comptest run hello --unknown", + context, + ); + expect(result).toBeDefined(); + // "@comptest run hello --unknown" (29 chars) + // suffix is "hello --unknown", "hello" fills the "task" arg, + // "--unknown" is not a defined flag → remainderLength = 9. + // startIndex = 29 - 9 = 20, then unconditional whitespace + // backing rewinds over the space → 19. + expect(result!.startIndex).toBe(19); + }); + + it("startIndex backs over multiple spaces before unconsumed remainder", async () => { + const result = await getCommandCompletion( + "@comptest run hello --unknown", + context, + ); + expect(result).toBeDefined(); + // "@comptest run hello --unknown" (31 chars) + // suffix is "hello --unknown", "hello" fills "task", + // "--unknown" unconsumed → remainderLength = 9. + // startIndex = 31 - 9 = 22, then unconditional whitespace + // backing rewinds over three spaces → 19. + expect(result!.startIndex).toBe(19); + }); + }); + + describe("separatorMode for command completions", () => { + it("returns separatorMode for subcommand completions at agent boundary", async () => { + const result = await getCommandCompletion("@comptest ", context); + expect(result).toBeDefined(); + // "run" is the default subcommand, so subcommand alternatives + // are included and the group has separatorMode: "space". + expect(result!.separatorMode).toBe("space"); + // startIndex excludes trailing whitespace (matching grammar + // matcher behaviour where matchedPrefixLength doesn't include the + // separator). + expect(result!.startIndex).toBe(9); + }); + + it("returns separatorMode for resolved agent without trailing space", async () => { + const result = await getCommandCompletion("@comptest", context); + expect(result).toBeDefined(); + expect(result!.separatorMode).toBe("space"); + // No trailing whitespace to trim — startIndex stays at end + expect(result!.startIndex).toBe(9); + // Default subcommand has agent completions → not exhaustive. + expect(result!.closedSet).toBe(false); + }); + + it("does not set separatorMode at top level (@)", async () => { + const result = await getCommandCompletion("@", context); + expect(result).toBeDefined(); + // Top-level completions (agent names, system subcommands) + // follow '@' — space is accepted but not required. + expect(result!.separatorMode).toBe("optional"); + // Agent names are offered when no agent was recognized, + // independent of which branch (descriptor/table/neither) + // produced the subcommand completions. + const agentGroup = result!.completions.find( + (g) => g.name === "Agent Names", + ); + expect(agentGroup).toBeDefined(); + expect(agentGroup!.completions).toContain("comptest"); + // Subcommand + agent name sets are finite → exhaustive. + expect(result!.closedSet).toBe(true); + }); + + it("does not set separatorMode for parameter completions only", async () => { + const result = await getCommandCompletion( + "@comptest run bu", + context, + ); + expect(result).toBeDefined(); + // Partial parameter token — only parameter completions returned, + // no subcommand group, so separatorMode is not set. + expect(result!.separatorMode).toBeUndefined(); + }); + + it("returns no separatorMode for partial unmatched token consumed as param", async () => { + const result = await getCommandCompletion("@comptest ne", context); + expect(result).toBeDefined(); + // "ne" is fully consumed as the "task" arg by parameter + // parsing. No trailing space → backs up to the start + // of "ne" → parameterCompletions.startIndex = 9. Since + // startIndex (9) ≤ commandConsumedLength (10), sibling + // subcommands are included with separatorMode="space". + expect(result!.separatorMode).toBe("space"); + const subcommands = result!.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeDefined(); + expect(result!.startIndex).toBe(9); + }); + }); + + describe("closedSet flag", () => { + it("returns empty completions for command with no parameters", async () => { + const result = await getCommandCompletion( + "@comptest noop ", + context, + ); + // "noop" has no parameters at all → nothing more to type. + // getCommandParameterCompletion returns undefined and + // no subcommand alternatives exist (explicit match) → + // empty completions with closedSet=true. + expect(result.completions).toHaveLength(0); + expect(result.closedSet).toBe(true); + }); + + it("closedSet=true for flags-only command with no args unfilled", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly ", + context, + ); + expect(result).toBeDefined(); + // No positional args, only flags. nextArgs is empty. + // No agent getCompletion. Flags are a finite set → exhaustive. + expect(result!.closedSet).toBe(true); + const flags = result!.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeDefined(); + expect(flags!.completions).toContain("--debug"); + expect(flags!.completions).toContain("--level"); + }); + + it("closedSet=true for boolean flag pending", async () => { + const result = await getCommandCompletion( + "@comptest nested sub --verbose ", + context, + ); + expect(result).toBeDefined(); + // --verbose is boolean; getPendingFlag pushed ["true", "false"] + // and returned undefined (not a pending non-boolean flag). + // nextArgs still has "value" unfilled → closedSet = false. + expect(result!.closedSet).toBe(false); + }); + + it("closedSet=false when agent completions are invoked without closedSet flag", async () => { + const result = await getCommandCompletion( + "@comptest run ", + context, + ); + expect(result).toBeDefined(); + // Agent getCompletion is invoked but does not set closedSet → + // defaults to false. + expect(result!.closedSet).toBe(false); + }); + + it("closedSet=true when agent returns closedSet=true", async () => { + const result = await getCommandCompletion( + "@comptest exhaustive ", + context, + ); + expect(result).toBeDefined(); + const colors = result!.completions.find((g) => g.name === "Colors"); + expect(colors).toBeDefined(); + expect(colors!.completions).toContain("red"); + expect(colors!.completions).toContain("green"); + expect(colors!.completions).toContain("blue"); + // Agent explicitly signals exhaustive completions. + expect(result!.closedSet).toBe(true); + }); + + it("closedSet=false when agent returns closedSet=false", async () => { + const result = await getCommandCompletion( + "@comptest nonexhaustive ", + context, + ); + expect(result).toBeDefined(); + const items = result!.completions.find((g) => g.name === "Items"); + expect(items).toBeDefined(); + expect(items!.completions).toContain("apple"); + expect(items!.completions).toContain("banana"); + // Agent explicitly signals non-exhaustive. + expect(result!.closedSet).toBe(false); + }); + + it("closedSet=false when agent does not set closedSet field", async () => { + const result = await getCommandCompletion( + "@comptest nocompletefield ", + context, + ); + expect(result).toBeDefined(); + const things = result!.completions.find((g) => g.name === "Things"); + expect(things).toBeDefined(); + expect(things!.completions).toContain("widget"); + expect(things!.completions).toContain("gadget"); + // Agent omits closedSet → defaults to false. + expect(result!.closedSet).toBe(false); + }); + + it("closedSet=false for unfilled positional args without agent", async () => { + const result = await getCommandCompletion( + "@comptest nested sub ", + context, + ); + expect(result).toBeDefined(); + // "value" arg is unfilled, no agent getCompletion → not exhaustive + // (free-form text). + expect(result!.closedSet).toBe(false); + }); + + it("closedSet=true for flags-only after one flag is set", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --debug true ", + context, + ); + expect(result).toBeDefined(); + // --debug is consumed; only --level remains. Still a finite set. + expect(result!.closedSet).toBe(true); + }); + + it("returns flag names for non-boolean flag without trailing space", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --level", + context, + ); + // "--level" is a recognized number flag, but no trailing + // space → user hasn't committed. Offer flag names at the + // tokenBoundary before "--level" (position 19, end of + // "flagsonly") instead of flag values. + expect(result.startIndex).toBe(19); + const flags = result.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeDefined(); + expect(flags!.completions).toContain("--debug"); + expect(flags!.completions).toContain("--level"); + }); + + it("treats unrecognized flag prefix as filter text", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --lev", + context, + ); + // "--lev" doesn't resolve (exact match only), so parseParams + // leaves it unconsumed. startIndex points to where "--lev" + // starts — it is the filter text. + // "@comptest flagsonly " = 20 chars consumed, then + // unconditional whitespace backing → 19. + expect(result.startIndex).toBe(19); + const flags = result.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeDefined(); + expect(flags!.completions).toContain("--debug"); + expect(flags!.completions).toContain("--level"); + }); + }); + + describe("flat descriptor (no subcommand table)", () => { + it("returns parameter completions for flat agent", async () => { + const result = await getCommandCompletion("@flattest ", context); + // flattest has no subcommand table (table===undefined), + // but its descriptor has parameters (args + flags). + // Should return flag completions. + const flags = result.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeDefined(); + expect(flags!.completions).toContain("--release"); + // Unfilled "target" arg → not exhaustive. + expect(result.closedSet).toBe(false); + }); + + it("returns correct startIndex for flat agent with partial token", async () => { + const result = await getCommandCompletion( + "@flattest --rel", + context, + ); + // "@flattest --rel" (15 chars) + // startIndex = 15 - 5 ("--rel") = 10, then unconditional + // whitespace backing rewinds over space → 9. + expect(result.startIndex).toBe(9); + expect(result.closedSet).toBe(false); + }); + + it("falls back to system for agent with no commands", async () => { + const result = await getCommandCompletion("@nocmdtest ", context); + // nocmdtest has no getCommands → not command-enabled → + // resolveCommand falls back to system agent. System has + // a subcommand table, so we get system subcommands. + const subcommands = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeDefined(); + expect(subcommands!.completions.length).toBeGreaterThan(0); + // nocmdtest IS a recognized agent name (just not + // command-enabled), so parsedAppAgentName is set and + // agent names are NOT offered. + const agentGroup = result.completions.find( + (g) => g.name === "Agent Names", + ); + expect(agentGroup).toBeUndefined(); + }); + }); + + describe("subcommands dropped when parameters consume past boundary", () => { + it("drops subcommands when default command parameter is filled", async () => { + const result = await getCommandCompletion( + "@comptest build ", + context, + ); + // "@comptest build " (16 chars) + // Resolves to default "run" (not explicit match). + // "build" fills the "task" arg, trailing space present. + // remainderLength = 0 → startIndex = 16, then unconditional + // whitespace backing → 15, past the command boundary (10). + // Subcommand names are no longer relevant at this + // position; only parameter completions remain. + const subcommands = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeUndefined(); + expect(result.startIndex).toBe(15); + // All positional args filled, no flags → exhaustive. + expect(result.closedSet).toBe(true); + }); + + it("keeps subcommands when at the command boundary", async () => { + const result = await getCommandCompletion("@comptest ", context); + // "@comptest " (10 chars) + // Resolves to default "run" — suffix is empty, parameter + // startIndex equals commandBoundary → subcommands included. + const subcommands = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeDefined(); + expect(subcommands!.completions).toContain("run"); + expect(subcommands!.completions).toContain("nested"); + }); + + it("includes subcommands when no trailing space at default command", async () => { + const result = await getCommandCompletion("@comptest ne", context); + // "@comptest ne" — suffix is "ne", parameter parsing + // fully consumes it as the "task" arg. No trailing space + // backs up startIndex to 9, which is ≤ commandBoundary + // (10), so subcommands ARE included. + const subcommands = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeDefined(); + expect(subcommands!.completions).toContain("nested"); + }); + }); + + describe("lastCompletableParam adjusts startIndex", () => { + it("backs startIndex to open-quote token start for '@comptest run \"bu'", async () => { + const result = await getCommandCompletion( + '@comptest run "bu', + context, + ); + // '@comptest run "bu' (17 chars) + // suffix is '"bu', parseParams consumes the open-quoted + // token through EOF → remainderLength = 0. + // lastCompletableParam = "task", quoted = false (open quote). + // Exclusive path: startIndex = 17 - 3 = 14, then unconditional + // whitespace backing rewinds over the space before '"bu' → 13. + expect(result.startIndex).toBe(13); + // Agent was invoked → not exhaustive. + expect(result.closedSet).toBe(false); + // Flag groups and nextArgs completions should be cleared. + const flags = result.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeUndefined(); + }); + + it("backs startIndex for multi-arg open quote '@comptest twoarg \"partial'", async () => { + const result = await getCommandCompletion( + '@comptest twoarg "partial', + context, + ); + // '@comptest twoarg "partial' (25 chars) + // suffix is '"partial', parseParams consumes open quote + // through EOF → remainderLength = 0. + // lastCompletableParam = "first", quoted = false. + // Exclusive path: startIndex = 25 - 8 = 17, then unconditional + // whitespace backing rewinds over the space → 16. + // "second" from nextArgs should NOT be in agentCommandCompletions. + expect(result.startIndex).toBe(16); + expect(result.closedSet).toBe(false); + }); + + it("backs startIndex for implicitQuotes '@comptest search hello world'", async () => { + const result = await getCommandCompletion( + "@comptest search hello world", + context, + ); + // "@comptest search hello world" (28 chars) + // suffix is "hello world", implicitQuotes consumes rest + // of line → remainderLength = 0, token = "hello world". + // lastCompletableParam = "query", lastParamImplicitQuotes = true. + // Exclusive path: startIndex = 28 - 11 = 17, then unconditional + // whitespace backing rewinds over the space → 16. + expect(result.startIndex).toBe(16); + expect(result.closedSet).toBe(false); + }); + + it("does not adjust startIndex for fully-quoted token", async () => { + const result = await getCommandCompletion( + '@comptest run "build"', + context, + ); + // '@comptest run "build"' (21 chars) + // Token '"build"' is fully quoted → isFullyQuoted returns true. + // Fully-quoted tokens are committed by their closing quote; + // neither lastCompletableParam nor the fallback back-up fires. + // startIndex stays at 21. + expect(result.startIndex).toBe(21); + expect(result.closedSet).toBe(true); + }); + + it("adjusts startIndex for bare unquoted token without trailing space", async () => { + const result = await getCommandCompletion( + "@comptest run bu", + context, + ); + // "bu" is not quoted → isFullyQuoted returns undefined. + // No trailing space → lastCompletableParam exclusive path + // fires: backs up startIndex to the start of "bu" → 13. + // Agent IS invoked for "task" completions. + expect(result.startIndex).toBe(13); + expect(result.closedSet).toBe(false); + }); + }); + + describe("groupPrefixLength overrides startIndex", () => { + it("open-quote CJK advances startIndex by matchedPrefixLength", async () => { + const result = await getCommandCompletion( + '@comptest grammar "東京タ', + context, + ); + // '@comptest grammar "東京タ' (22 chars) + // 0-8: @comptest 9: sp 10-16: grammar 17: sp + // 18: " 19: 東 20: 京 21: タ + // suffix = '"東京タ' (4 chars), open-quoted token. + // lastCompletableParam fires (quoted=false). + // tokenStartIndex = 22 - 4 = 18 (position of '"') + // startIndex = tokenBoundary(input, 18) = 17 + // Agent strips opening quote, matches "東京" (2 chars), + // returns matchedPrefixLength = 3 (1 quote + 2 CJK chars). + // startIndex = tokenStartIndex + 3 = 18 + 3 = 21. + // rawPrefix = "タ", "タワー".startsWith("タ") ✓ + expect(result.startIndex).toBe(21); + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeDefined(); + expect(grammar!.completions).toContain("タワー"); + expect(grammar!.completions).toContain("駅"); + expect(result.closedSet).toBe(false); + }); + + it("implicitQuotes CJK advances startIndex by matchedPrefixLength", async () => { + const result = await getCommandCompletion( + "@comptest grammariq 東京タ", + context, + ); + // "@comptest grammariq 東京タ" (23 chars) + // 0-8: @comptest 9: sp 10-18: grammariq 19: sp + // 20: 東 21: 京 22: タ + // suffix = "東京タ" (3 chars), implicitQuotes captures + // rest of line as token. + // lastCompletableParam fires (implicitQuotes). + // tokenStartIndex = 23 - 3 = 20 (position of "東") + // startIndex = tokenBoundary(input, 20) = 19 + // Agent matches "東京" (2 chars), returns matchedPrefixLength=2. + // startIndex = tokenStartIndex + 2 = 20 + 2 = 22. + // rawPrefix = "タ", "タワー".startsWith("タ") ✓ + expect(result.startIndex).toBe(22); + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeDefined(); + expect(grammar!.completions).toContain("タワー"); + expect(grammar!.completions).toContain("駅"); + expect(result.closedSet).toBe(false); + }); + + it("fully-quoted token does not invoke grammar", async () => { + const result = await getCommandCompletion( + '@comptest grammar "東京タ"', + context, + ); + // Token '"東京タ"' is fully quoted → isFullyQuoted = true. + // Fully-quoted tokens are committed; neither + // lastCompletableParam nor the fallback back-up fires. + // startIndex stays at 23. + expect(result.startIndex).toBe(23); + expect(result.closedSet).toBe(true); + // No Grammar group since agent wasn't invoked. + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeUndefined(); + }); + + it("bare unquoted token invokes grammar without trailing space", async () => { + const result = await getCommandCompletion( + "@comptest grammar 東京タ", + context, + ); + // "東京タ" has no quotes and no trailing space. + // lastCompletableParam exclusive path fires + // (!hasTrailingSpace && pendingFlag === undefined). + // Agent is invoked with grammar mock → matches "東京" → + // returns matchedPrefixLength=2. tokenStartIndex = 21-3 = 18, + // startIndex = 18 + 2 = 20. + expect(result.startIndex).toBe(20); + expect(result.closedSet).toBe(false); + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeDefined(); + expect(grammar!.completions).toContain("タワー"); + expect(grammar!.completions).toContain("駅"); + }); + + it("trailing space without text offers initial completions", async () => { + const result = await getCommandCompletion( + "@comptest grammar ", + context, + ); + // "@comptest grammar " (18 chars) + // suffix is "", no tokens parsed → nextArgs = ["phrase"]. + // Agent called, mock sees empty token list → returns + // completions ["東京"] with no matchedPrefixLength. + // groupPrefixLength path does not fire. + // startIndex = tokenBoundary(input, 18) = 17. + expect(result.startIndex).toBe(17); + expect(result.closedSet).toBe(false); + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeDefined(); + expect(grammar!.completions).toContain("Tokyo "); + expect(grammar!.completions).toContain("東京"); + }); + + it("clears earlier completions when matchedPrefixLength is set", async () => { + const result = await getCommandCompletion( + '@comptest grammar "東京タ', + context, + ); + // When groupPrefixLength fires, parameter/flag + // completions from before the agent call are cleared. + const flags = result.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeUndefined(); + }); + + it("does not override startIndex when matchedPrefixLength is absent", async () => { + // "run" handler returns groups without matchedPrefixLength. + // No trailing space → backs up to start of "bu". + const result = await getCommandCompletion( + "@comptest run bu", + context, + ); + expect(result.startIndex).toBe(13); + }); + + it("English prefix with space separator", async () => { + const result = await getCommandCompletion( + "@comptest grammariq Tokyo T", + context, + ); + // "@comptest grammariq Tokyo T" (27 chars) + // 0-8: @comptest 9: sp 10-18: grammariq 19: sp + // 20-24: Tokyo 25: sp 26: T + // Token = "Tokyo T" (7 chars), implicitQuotes. + // lastCompletableParam fires. + // tokenStartIndex = 27 - 7 = 20 + // startIndex = tokenBoundary(input, 20) = 19 + // Mock matches "Tokyo" → matchedPrefixLength=5, separatorMode="space". + // startIndex = tokenStartIndex + 5 = 20 + 5 = 25. + // rawPrefix = " T", consumer strips space → filter "T". + // "Tower".startsWith("T") ✓ + expect(result.startIndex).toBe(25); + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeDefined(); + expect(grammar!.completions).toContain("Tower"); + expect(grammar!.completions).toContain("Station"); + expect(result.closedSet).toBe(false); + }); + + it("completed CJK match returns no completions", async () => { + const result = await getCommandCompletion( + "@comptest grammariq 東京タワー", + context, + ); + // Token = "東京タワー" (5 chars). Mock matches "東京" + // and finds suffix "タワー" starts with "タワー" → + // returns empty (completed match, no more to suggest). + // agentGroups is [], no matchedPrefixLength. + // startIndex = tokenBoundary from lastCompletableParam path. + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeUndefined(); + expect(result.closedSet).toBe(false); + }); + + it("no-text offers initial completions via grammariq", async () => { + const result = await getCommandCompletion( + "@comptest grammariq ", + context, + ); + // "@comptest grammariq " (20 chars) + // No tokens parsed → nextArgs = ["query"]. + // Mock sees empty token → falls to "no prefix matched" + // branch → completions: ["Tokyo ", "東京"], + // matchedPrefixLength: 0, separatorMode: "space". + // groupPrefixLength = 0 → condition false → skip. + // startIndex = tokenBoundary(input, 20) = 19. + expect(result.startIndex).toBe(19); + const grammar = result.completions.find( + (g) => g.name === "Grammar", + ); + expect(grammar).toBeDefined(); + expect(grammar!.completions).toContain("Tokyo "); + expect(grammar!.completions).toContain("東京"); + expect(result.closedSet).toBe(false); + }); + }); + + describe("Bug 1: fallback startIndex uses tokenBoundary", () => { + // When the agent has no getCommandCompletion, the fallback + // back-up path handles no-trailing-space. It must apply + // tokenBoundary() so startIndex lands at the end of the + // preceding token (before separator whitespace), matching + // the convention every other code path follows. + it("startIndex at tokenBoundary for '@comptest nested sub val' (no agent getCommandCompletion)", async () => { + const result = await getCommandCompletion( + "@comptest nested sub val", + context, + ); + // "@comptest nested sub val" (24 chars) + // 0-8: @comptest 9: sp 10-15: nested 16: sp + // 17-19: sub 20: sp 21-23: val + // "nested sub" has no getCommandCompletion, so the + // exclusive path inside `if (agent.getCommandCompletion)` + // is skipped. The fallback back-up fires because + // !hasTrailingSpace, remainderLength=0, tokens=["val"]. + // It should apply tokenBoundary to land at 20 (end of + // "sub"), not 21 (raw token start of "val"). + expect(result.startIndex).toBe(20); + }); + + it("startIndex at tokenBoundary for '@comptest nested sub --verbose val' (no agent getCommandCompletion)", async () => { + const result = await getCommandCompletion( + "@comptest nested sub --verbose val", + context, + ); + // "@comptest nested sub --verbose val" (33 chars) + // 0-8: @comptest 9: sp 10-15: nested 16: sp + // 17-19: sub 20: sp 21-29: --verbose 30: sp + // 31-33: val + // --verbose is parsed as boolean flag (defaults true), + // then "val" fills the "value" arg. No trailing space. + // Fallback should land at tokenBoundary before "val" → 30 + // (end of "--verbose"), not 31. + expect(result.startIndex).toBe(30); + }); + }); + + describe("Bug 2: fallback does not fire when agent was invoked", () => { + // When the last consumed parameter is non-string (e.g. number), + // lastCompletableParam is undefined so the exclusive path + // doesn't fire. But the agent IS invoked for nextArgs. + // The fallback must NOT back up startIndex because the + // completions describe the NEXT position, not the current token. + it("does not back up over number arg for '@numstrtest numstr 42' (no trailing space)", async () => { + const result = await getCommandCompletion( + "@numstrtest numstr 42", + context, + ); + // "@numstrtest numstr 42" (21 chars) + // 0-10: @numstrtest 11: sp 12-17: numstr 18: sp + // 19-20: 42 + // suffix = "42", parseParams consumes 42 as number arg + // "count". remainderLength=0, lastCompletableParam=undefined + // (number type). nextArgs=["name"], agent invoked for + // "name" completions. Fallback should NOT fire because + // the agent was already invoked — startIndex should stay + // at the end of consumed text (21), not back up to 19. + expect(result.startIndex).toBe(21); + // Agent was invoked for "name" completions. + const names = result.completions.find((g) => g.name === "Names"); + expect(names).toBeDefined(); + expect(names!.completions).toContain("alice"); + expect(names!.completions).toContain("bob"); + expect(result.closedSet).toBe(false); + }); + + it("baseline: '@numstrtest numstr 42 ' with trailing space works correctly", async () => { + const result = await getCommandCompletion( + "@numstrtest numstr 42 ", + context, + ); + // "@numstrtest numstr 42 " (22 chars) + // Trailing space → hasTrailingSpace=true, fallback never + // fires. startIndex = tokenBoundary(input, 22) = 21 + // (rewinds over trailing space to end of "42"). + // Agent invoked for "name" completions. + expect(result.startIndex).toBe(21); + const names = result.completions.find((g) => g.name === "Names"); + expect(names).toBeDefined(); + expect(names!.completions).toContain("alice"); + expect(names!.completions).toContain("bob"); + expect(result.closedSet).toBe(false); + }); + + it("does not back up over number arg for '@numstrtest numstr 42 al' (partial second arg)", async () => { + const result = await getCommandCompletion( + "@numstrtest numstr 42 al", + context, + ); + // "@numstrtest numstr 42 al" (24 chars) + // suffix = "42 al", parseParams: 42 → count, "al" → name. + // lastCompletableParam = "name" (string), no trailing space. + // Exclusive path fires (bare token, !hasTrailingSpace): + // backs up to before "al" → tokenBoundary(input, 22) = 21. + // Agent invoked for "name". + expect(result.startIndex).toBe(21); + const names = result.completions.find((g) => g.name === "Names"); + expect(names).toBeDefined(); + expect(names!.completions).toContain("alice"); + expect(names!.completions).toContain("bob"); + expect(result.closedSet).toBe(false); + }); + }); +}); diff --git a/ts/packages/dispatcher/dispatcher/test/remainder.spec.ts b/ts/packages/dispatcher/dispatcher/test/remainder.spec.ts new file mode 100644 index 000000000..e7cad0dfd --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/remainder.spec.ts @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { parseParams } from "../src/command/parameters.js"; + +describe("remainderLength", () => { + // ----- Parameter definitions reused across tests ----- + const singleArg = { + args: { + name: { description: "a name" }, + }, + } as const; + + const twoArgs = { + args: { + first: { description: "first" }, + second: { description: "second" }, + }, + } as const; + + const multipleArg = { + args: { + items: { description: "items", multiple: true }, + }, + } as const; + + const multipleAndSingle = { + args: { + items: { description: "items", multiple: true }, + last: { description: "last", optional: true }, + }, + } as const; + + const strFlag = { + flags: { + str: { description: "string flag", type: "string" }, + }, + } as const; + + const numFlag = { + flags: { + num: { description: "number flag", type: "number" }, + }, + } as const; + + const boolFlag = { + flags: { + bool: { description: "boolean flag", type: "boolean" }, + }, + } as const; + + const flagsAndArgs = { + flags: { + bool: { description: "boolean flag", type: "boolean" }, + }, + args: { + name: { description: "a name" }, + }, + } as const; + + const implicitQuoteArg = { + args: { + text: { description: "text", implicitQuotes: true }, + }, + }; + + const optionalArg = { + args: { + name: { description: "a name", optional: true }, + }, + } as const; + + // ---- Non-partial mode (entire input consumed → 0) ---- + + describe("non-partial", () => { + it("empty input", () => { + expect(parseParams("", optionalArg).remainderLength).toBe(0); + }); + + it("single argument consumed", () => { + expect(parseParams("hello", singleArg).remainderLength).toBe(0); + }); + + it("two arguments consumed", () => { + expect(parseParams("hello world", twoArgs).remainderLength).toBe(0); + }); + + it("multiple arguments consumed", () => { + expect(parseParams("a b c", multipleArg).remainderLength).toBe(0); + }); + + it("flag with string value consumed", () => { + expect(parseParams("--str value", strFlag).remainderLength).toBe(0); + }); + + it("boolean flag consumed", () => { + expect(parseParams("--bool", boolFlag).remainderLength).toBe(0); + }); + + it("boolean flag with explicit true consumed", () => { + expect(parseParams("--bool true", boolFlag).remainderLength).toBe( + 0, + ); + }); + + it("flags and args consumed", () => { + expect( + parseParams("--bool hello", flagsAndArgs).remainderLength, + ).toBe(0); + }); + + it("quoted argument consumed", () => { + expect( + parseParams("'hello world'", singleArg).remainderLength, + ).toBe(0); + }); + + it("whitespace-padded input trimmed and consumed", () => { + expect(parseParams(" hello ", singleArg).remainderLength).toBe(0); + }); + + it("implicit quote argument consumes rest of line", () => { + expect( + parseParams("hello world extra", implicitQuoteArg) + .remainderLength, + ).toBe(0); + }); + + it("separator between multiple and single arg", () => { + expect( + parseParams("a b -- c", multipleAndSingle).remainderLength, + ).toBe(0); + }); + + it("flag with value plus argument consumed", () => { + expect( + parseParams("--str value hello", { + flags: { str: { description: "s", type: "string" } }, + args: { name: { description: "n" } }, + } as const).remainderLength, + ).toBe(0); + }); + }); + + // ---- Partial mode — fully consumed ---- + + describe("partial - fully consumed", () => { + it("empty input", () => { + expect(parseParams("", optionalArg, true).remainderLength).toBe(0); + }); + + it("single argument", () => { + expect(parseParams("hello", singleArg, true).remainderLength).toBe( + 0, + ); + }); + + it("flag with value", () => { + expect( + parseParams("--str value", strFlag, true).remainderLength, + ).toBe(0); + }); + + it("boolean flag without value", () => { + expect(parseParams("--bool", boolFlag, true).remainderLength).toBe( + 0, + ); + }); + + it("trailing whitespace after completed arg", () => { + expect(parseParams("hello ", singleArg, true).remainderLength).toBe( + 0, + ); + }); + + it("multiple arguments", () => { + expect( + parseParams("a b c", multipleArg, true).remainderLength, + ).toBe(0); + }); + + it("flags and args", () => { + expect( + parseParams("--bool hello", flagsAndArgs, true).remainderLength, + ).toBe(0); + }); + }); + + // ---- Partial mode — partially consumed ---- + + describe("partial - partially consumed", () => { + it("too many arguments leaves remainder", () => { + const result = parseParams("hello extra", singleArg, true); + expect(result.remainderLength).toBe("extra".length); + }); + + it("invalid flag leaves full input as remainder", () => { + const result = parseParams("--unknown", strFlag, true); + expect(result.remainderLength).toBe("--unknown".length); + }); + + it("string flag missing value with next flag-like token", () => { + // --str consumed as flag name, but --other is not a valid value + // so curr is rolled back to "--other" + const result = parseParams("--str --other", strFlag, true); + expect(result.remainderLength).toBe("--other".length); + }); + + it("string flag missing value at end of input", () => { + // Flag name consumed, no more input → remainder is 0 + const result = parseParams("--str", strFlag, true); + expect(result.remainderLength).toBe(0); + }); + + it("number flag with non-numeric value", () => { + // --num consumed as flag name, "abc" fails number parse and + // is rolled back + const result = parseParams("--num abc", numFlag, true); + expect(result.remainderLength).toBe("abc".length); + }); + + it("boolean flag defaults true then extra has no arg def", () => { + // --bool consumed with default true, "extra" has no arg def + const result = parseParams("--bool extra", boolFlag, true); + expect(result.remainderLength).toBe("extra".length); + }); + + it("multiple arg terminated by invalid flag-like token", () => { + const result = parseParams("a b --bad", multipleArg, true); + expect(result.remainderLength).toBe("--bad".length); + }); + + it("two valid args then too many", () => { + const result = parseParams("hello world extra", twoArgs, true); + expect(result.remainderLength).toBe("extra".length); + }); + + it("valid flag+value then invalid flag", () => { + const result = parseParams("--str hello --unknown", strFlag, true); + expect(result.remainderLength).toBe("--unknown".length); + }); + + it("leading whitespace is trimmed before measuring", () => { + // partial trims only the start; "extra" is 5 chars + const result = parseParams(" hello extra", singleArg, true); + expect(result.remainderLength).toBe("extra".length); + }); + }); +}); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts index 57b7cc7b2..dae164a64 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -41,9 +41,7 @@ export type DispatcherInvokeFunctions = { propertyName: string, ): Promise; - getCommandCompletion( - prefix: string, - ): Promise; + getCommandCompletion(prefix: string): Promise; checkCache(request: string): Promise; diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index e3f537b9f..edc6f2b52 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -2,9 +2,11 @@ // Licensed under the MIT License. import { + CommitMode, CompletionGroup, DisplayType, DynamicDisplay, + SeparatorMode, TemplateSchema, TypeAgentAction, } from "@typeagent/agent-sdk"; @@ -72,8 +74,28 @@ export type CommandResult = { }; export type CommandCompletionResult = { - startIndex: number; // index of first character of the filter text (after the last space) + // Index into the input where the resolved prefix ends and the + // filter/completion region begins. input[0..startIndex) is fully + // resolved; completions describe what can follow after that prefix. + startIndex: number; completions: CompletionGroup[]; // completions available at the current position + // What kind of separator is required between the matched prefix and + // the completion text. When omitted, defaults to "space". + // See SeparatorMode in @typeagent/agent-sdk. + separatorMode?: SeparatorMode | undefined; + // True when the completions form a closed set — if the user types + // something not in the list, no further completions can exist + // beyond it. When true and the user types something that doesn't + // prefix-match any completion, the caller can skip refetching since + // no other valid input exists. + closedSet: boolean; + // Controls when a uniquely-satisfied completion triggers a re-fetch + // for the next hierarchical level. + // "explicit" — user must type a delimiter to commit; suppresses + // eager re-fetch on unique match. + // "eager" — re-fetch immediately on unique satisfaction. + // When omitted, defaults to "explicit". + commitMode?: CommitMode; }; export type AppAgentStatus = { @@ -178,9 +200,7 @@ export interface Dispatcher { ): Promise; // APIs to get command completion for intellisense like functionality. - getCommandCompletion( - prefix: string, - ): Promise; + getCommandCompletion(prefix: string): Promise; // Check if a request can be handled by cache without executing checkCache(request: string): Promise; diff --git a/ts/packages/shell/jest.config.cjs b/ts/packages/shell/jest.config.cjs new file mode 100644 index 000000000..25456e93b --- /dev/null +++ b/ts/packages/shell/jest.config.cjs @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +module.exports = require("../../jest.config.js"); diff --git a/ts/packages/shell/package.json b/ts/packages/shell/package.json index 7d5f425e2..3743fa612 100644 --- a/ts/packages/shell/package.json +++ b/ts/packages/shell/package.json @@ -19,7 +19,7 @@ "src/**/*ActionSchema*" ], "scripts": { - "build": "concurrently npm:prepare-vite npm:build:electron:esm", + "build": "concurrently npm:prepare-vite npm:build:electron:esm npm:tsc:model", "build:electron:cjs": "electron-vite build src/preload --config electron.vite.preload-cjs.config.mts --logLevel error", "build:electron:esm": "electron-vite build", "clean": "rimraf --glob out dist deploy *.tsbuildinfo *.done.build.log", @@ -48,6 +48,9 @@ "start:package:linux": "./dist/linux-unpacked/typeagentshell", "start:package:macos": "open ./dist/mac-arm64/TypeAgent\\ Shell.app", "start:package:win32": ".\\dist\\win-unpacked\\typeagentshell.exe", + "jest-esm": "node --no-warnings --experimental-vm-modules ./node_modules/jest/bin/jest.js", + "test:local": "pnpm run jest-esm --testPathPattern=\".*[.]spec[.]js\"", + "tsc:model": "tsc -b src test", "typecheck": "concurrently npm:typecheck:node npm:typecheck:web", "typecheck:node": "tsc -p tsconfig.node.json", "typecheck:web": "tsc -p tsconfig.web.json" @@ -85,12 +88,15 @@ "@fontsource/lato": "^5.2.5", "@playwright/test": "^1.55.0", "@types/debug": "^4.1.12", + "@jest/globals": "^29.7.0", + "@types/jest": "^29.5.7", "concurrently": "^9.1.2", "cross-env": "^7.0.3", "electron": "40.6.0", "electron-builder": "26.8.1", "electron-builder-squirrel-windows": "26.8.1", "electron-vite": "^4.0.1", + "jest": "^29.7.0", "less": "^4.2.0", "rimraf": "^6.0.1", "run-script-os": "^1.1.6", diff --git a/ts/packages/shell/src/main/agent.ts b/ts/packages/shell/src/main/agent.ts index a9b2fd83e..3bcc5fc1f 100644 --- a/ts/packages/shell/src/main/agent.ts +++ b/ts/packages/shell/src/main/agent.ts @@ -6,7 +6,7 @@ import { AppAgent, AppAgentInitSettings, AppAgentManifest, - CompletionGroup, + CompletionGroups, ParsedCommandParams, PartialParsedCommandParams, SessionContext, @@ -209,11 +209,11 @@ class ShellSetSettingCommandHandler implements CommandHandler { context: SessionContext, params: PartialParsedCommandParams, names: string[], - ): Promise { - const completions: CompletionGroup[] = []; + ): Promise { + const completions: CompletionGroups = { groups: [] }; for (const name of names) { if (name === "name") { - completions.push({ + completions.groups.push({ name, completions: getObjectPropertyNames( context.agentContext.shellWindow.getUserSettings(), @@ -228,7 +228,7 @@ class ShellSetSettingCommandHandler implements CommandHandler { context.agentContext.shellWindow.getUserSettings(); const value = getObjectProperty(settings, settingName); if (typeof value === "boolean") { - completions.push({ + completions.groups.push({ name, completions: ["true", "false"], }); diff --git a/ts/packages/shell/src/preload/electronTypes.ts b/ts/packages/shell/src/preload/electronTypes.ts index 424557325..c6cbbf05a 100644 --- a/ts/packages/shell/src/preload/electronTypes.ts +++ b/ts/packages/shell/src/preload/electronTypes.ts @@ -93,7 +93,7 @@ export interface Client { focusInput(): void; titleUpdated(title: string): void; - searchMenuCompletion(id: number, item: SearchMenuItem); + searchMenuCompletion(id: number, item: SearchMenuItem): void; continuousSpeechProcessed(userExpressions: UserExpression[]): void; tabRestoreStatus(count: number): void; systemNotification?(message: string, id: string, timestamp: number): void; diff --git a/ts/packages/shell/src/renderer/src/partial.ts b/ts/packages/shell/src/renderer/src/partial.ts index 85e23b9b8..f9c0af602 100644 --- a/ts/packages/shell/src/renderer/src/partial.ts +++ b/ts/packages/shell/src/renderer/src/partial.ts @@ -1,9 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { CommandCompletionResult, Dispatcher } from "agent-dispatcher"; +import { Dispatcher } from "agent-dispatcher"; import { SearchMenu } from "./search"; import { SearchMenuItem } from "./searchMenuUI/searchMenuUI"; +import { + ICompletionDispatcher, + ISearchMenu, + PartialCompletionSession, +} from "./partialCompletionSession"; import registerDebug from "debug"; import { ExpandableTextArea } from "./chat/expandableTextArea"; @@ -45,18 +50,14 @@ function getLeafNode(node: Node, offset: number) { export class PartialCompletion { private readonly searchMenu: SearchMenu; - private current: string | undefined = undefined; - private noCompletion: boolean = false; - private completionP: - | Promise - | undefined; + private readonly session: PartialCompletionSession; public closed: boolean = false; private readonly cleanupEventListeners: () => void; constructor( private readonly container: HTMLDivElement, private readonly input: ExpandableTextArea, - private readonly dispatcher: Dispatcher, + dispatcher: Dispatcher, private readonly inline: boolean = true, ) { this.searchMenu = new SearchMenu( @@ -66,6 +67,17 @@ export class PartialCompletion { this.inline, this.input.getTextEntry(), ); + + // Wrap SearchMenu to implement ISearchMenu (same shape, just typed). + const menuAdapter: ISearchMenu = this.searchMenu; + // Wrap Dispatcher to implement ICompletionDispatcher. + const dispatcherAdapter: ICompletionDispatcher = dispatcher; + + this.session = new PartialCompletionSession( + menuAdapter, + dispatcherAdapter, + ); + const selectionChangeHandler = () => { debug("Partial completion update on selection changed"); this.update(false); @@ -93,88 +105,27 @@ export class PartialCompletion { this.input.getTextEntry().normalize(); } if (!this.isSelectionAtEnd(contentChanged)) { - this.cancelCompletionMenu(); + this.session.hide(); return; } const input = this.getCurrentInputForCompletion(); debug(`Partial completion input: '${input}'`); - // @ commands: use existing command completion path. - // Same token-boundary logic as grammar completions: only re-fetch - // at word boundaries so partial words (e.g. "@config c") don't hit - // the backend, which would fail to resolve "c" and poison noCompletion. - if (input.trimStart().startsWith("@")) { - if (this.reuseSearchMenu(input)) { - return; - } - // Re-fetch at the last word boundary so the backend sees only - // complete command tokens and returns proper subcommand completions. - const lastSpaceIdx = input.lastIndexOf(" "); - if (/\s$/.test(input)) { - this.updatePartialCompletion(input); - } else if (lastSpaceIdx >= 0) { - this.updatePartialCompletion( - input.substring(0, lastSpaceIdx + 1), - ); - } else { - this.updatePartialCompletion(input); - } - return; - } - - // Empty input: hide any open menu and stop — don't show completions on - // an empty box (e.g. after backspacing all the way). This check must - // come before reuseSearchMenu() because reuseSearchMenu("") would - // match current="" and show all items for the start-state request. - const trimmed = input.trimStart(); - if (trimmed.length === 0) { - this.cancelCompletionMenu(); - return; - } - - // Request completions: only request at token boundaries. - // Between boundaries, filter the existing menu locally. - if (this.reuseSearchMenu(input)) { - return; - } - - // Determine whether this is a token boundary: - // 1. Trailing space → complete tokens available, request with them - // 2. Non-empty with no spaces → first typing, request start state (tokens=[]) - // 3. Otherwise (mid-word after spaces, no menu) → wait for next space - const hasTrailingSpace = /\s$/.test(input); - const hasSpaces = /\s/.test(trimmed); - - if (hasTrailingSpace) { - // Token boundary: send full input (all tokens are complete) - this.updatePartialCompletion(input); - } else if (!hasSpaces) { - // Start state: request with "" so backend returns all initial completions. - // The typed characters (e.g. "p") become the local filter via current="". - this.updatePartialCompletion(""); - } else { - // Mid-word with spaces and no active menu (e.g. backspace removed - // a trailing space). Request at the last token boundary so the - // menu reappears with the partial word as the local filter. - const lastSpaceIdx = input.lastIndexOf(" "); - if (lastSpaceIdx >= 0) { - this.updatePartialCompletion( - input.substring(0, lastSpaceIdx + 1), - ); - } - } + this.session.update(input, (prefix) => + this.getSearchMenuPosition(prefix), + ); } public hide() { - this.completionP = undefined; - this.cancelCompletionMenu(); + this.session.hide(); } public close() { this.closed = true; - this.hide(); + this.session.hide(); this.cleanupEventListeners(); } + private getCurrentInput() { // Strip inline ghost text if present const textEntry = this.input.getTextEntry(); @@ -186,6 +137,7 @@ export class PartialCompletion { } return textEntry.textContent ?? ""; } + private getCurrentInputForCompletion() { return this.getCurrentInput().trimStart(); } @@ -258,136 +210,7 @@ export class PartialCompletion { return true; } - private getCompletionPrefix(input: string) { - const current = this.current; - if (current === undefined) { - return undefined; - } - if (!input.startsWith(current)) { - return undefined; - } - return input.substring(current.length); - } - - // Determine if the current search menu can still be reused, or if we need to update the completions. - // Returns true to reuse (skip re-fetch), false to trigger a new completion request. - private reuseSearchMenu(input: string) { - const current = this.current; - if (current === undefined) { - return false; - } - - if (this.completionP !== undefined) { - debug(`Partial completion pending: ${current}`); - return true; - } - - if (!input.startsWith(current)) { - // Input diverged (e.g. backspace past the anchor point). - return false; - } - - if (this.noCompletion) { - debug( - `Partial completion skipped: No completions for '${current}'`, - ); - return true; - } - - const prefix = this.getCompletionPrefix(input); - if (prefix === undefined) { - return false; - } - - const position = this.getSearchMenuPosition(prefix); - if (position !== undefined) { - debug( - `Partial completion update: '${prefix}' @ ${JSON.stringify(position)}`, - ); - this.searchMenu.updatePrefix(prefix, position); - } else { - this.searchMenu.hide(); - } - - // Reuse while menu has matches; re-fetch when all items are filtered out. - return this.searchMenu.isActive(); - } - - // Updating completions information with input - private updatePartialCompletion(input: string) { - debug(`Partial completion start: '${input}'`); - this.cancelCompletionMenu(); - this.current = input; - this.noCompletion = false; - // Clear the choices - this.searchMenu.setChoices([]); - const completionP = this.dispatcher.getCommandCompletion(input); - this.completionP = completionP; - completionP - .then((result) => { - if (this.completionP !== completionP) { - debug(`Partial completion canceled: '${input}'`); - return; - } - - this.completionP = undefined; - debug(`Partial completion result: `, result); - if (result === undefined) { - debug( - `Partial completion skipped: No completions for '${input}'`, - ); - this.noCompletion = true; - return; - } - - const partial = - result.startIndex >= 0 && result.startIndex <= input.length - ? input.substring(0, result.startIndex) - : input; - this.current = partial; - - // Build completions preserving backend group order so that - // grammar completions (e.g. "by") appear before entity - // completions (e.g. song titles), matching CLI behavior. - const completions: SearchMenuItem[] = []; - let currentIndex = 0; - for (const group of result.completions) { - const items = group.sorted - ? group.completions - : [...group.completions].sort(); - for (const choice of items) { - completions.push({ - matchText: choice, - selectedText: choice, - sortIndex: currentIndex++, - needQuotes: group.needQuotes, - emojiChar: group.emojiChar, - }); - } - } - - if (completions.length === 0) { - debug( - `Partial completion skipped: No current completions for '${partial}'`, - ); - return; - } - - this.searchMenu.setChoices(completions); - - debug( - `Partial completion selection updated: '${partial}' with ${completions.length} items`, - ); - this.update(false); - }) - .catch((e) => { - debugError(`Partial completion error: '${input}' ${e}`); - this.completionP = undefined; - }); - } - private getSearchMenuPosition(prefix: string) { - // The menu is not active or completion prefix is empty (i.e. need to move the menu). const textEntry = this.input.getTextEntry(); let x: number; if (textEntry.childNodes.length === 0) { @@ -416,27 +239,25 @@ export class PartialCompletion { return { left: x, bottom: window.innerHeight - top }; } - private cancelCompletionMenu() { - this.searchMenu.hide(); - } - private handleSelect(item: SearchMenuItem) { debug(`Partial completion selected: ${item.selectedText}`); - this.cancelCompletionMenu(); - const prefix = this.getCompletionPrefix( - this.getCurrentInputForCompletion(), - ); - if (prefix === undefined) { - // This should not happen. + this.searchMenu.hide(); + + // Compute the filter prefix relative to the current anchor. + // Must be read before resetToIdle() clears the session's anchor. + const currentInput = this.getCurrentInputForCompletion(); + const completionPrefix = this.session.getCompletionPrefix(currentInput); + if (completionPrefix === undefined) { debugError(`Partial completion abort select: prefix not found`); return; } + const replaceText = item.needQuotes !== false && /\s/.test(item.selectedText) ? `"${item.selectedText.replaceAll('"', '\\"')}"` : item.selectedText; - const offset = this.getCurrentInput().length - prefix.length; + const offset = this.getCurrentInput().length - completionPrefix.length; const leafNode = getLeafNode(this.input.getTextEntry(), offset); if (leafNode === undefined) { debugError( @@ -446,7 +267,7 @@ export class PartialCompletion { } const endLeafNode = getLeafNode( this.input.getTextEntry(), - offset + prefix.length, + offset + completionPrefix.length, ); if (endLeafNode === undefined) { debugError( @@ -462,21 +283,34 @@ export class PartialCompletion { r.deleteContents(); r.insertNode(newNode); - r.collapse(false); + // Normalize merges adjacent text nodes so getSelectionEndNode() + // returns a single text node. Then place the cursor at its end + // so isSelectionAtEnd() passes. Without this, r.collapse(false) + // leaves endContainer pointing at the parent element, which does + // not match the deepest-last-child that isSelectionAtEnd() expects. + const textEntry = this.input.getTextEntry(); + textEntry.normalize(); + const endNode = this.input.getSelectionEndNode(); + const cursorRange = document.createRange(); + cursorRange.setStart(endNode, endNode.textContent?.length ?? 0); + cursorRange.collapse(true); const s = document.getSelection(); if (s) { s.removeAllRanges(); - s.addRange(r); + s.addRange(cursorRange); } // Make sure the text entry remains focused after replacement. - this.input.getTextEntry().focus(); + textEntry.focus(); - // Reset completion state so the next update requests fresh - // completions from the backend instead of reusing stale trie data. - this.current = undefined; + // Reset completion state so the next update requests fresh completions. + this.session.resetToIdle(); debug(`Partial completion replaced: ${replaceText}`); + + // Explicitly trigger a completion update. The selectionchange event + // alone is unreliable after programmatic DOM manipulation. + this.update(false); } public handleSpecialKeys(event: KeyboardEvent) { @@ -484,7 +318,7 @@ export class PartialCompletion { return false; } if (event.key === "Escape") { - this.cancelCompletionMenu(); + this.searchMenu.hide(); event.preventDefault(); return true; } diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts new file mode 100644 index 000000000..5b252aed0 --- /dev/null +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -0,0 +1,440 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CommandCompletionResult } from "agent-dispatcher"; +import { + CommitMode, + CompletionGroup, + SeparatorMode, +} from "@typeagent/agent-sdk"; +import { + SearchMenuItem, + SearchMenuPosition, +} from "../../preload/electronTypes.js"; +import registerDebug from "debug"; + +const debug = registerDebug("typeagent:shell:partial"); +const debugError = registerDebug("typeagent:shell:partial:error"); + +export interface ISearchMenu { + setChoices(choices: SearchMenuItem[]): void; + // Returns true when the prefix uniquely satisfies exactly one entry + // (exact match that is not a prefix of any other entry). + updatePrefix(prefix: string, position: SearchMenuPosition): boolean; + // Returns true when text is an exact match for a completion entry. + hasExactMatch(text: string): boolean; + hide(): void; + isActive(): boolean; +} + +export interface ICompletionDispatcher { + getCommandCompletion(input: string): Promise; +} + +// PartialCompletionSession manages the state machine for command completion. +// +// States: +// IDLE anchor === undefined +// PENDING anchor !== undefined && completionP !== undefined +// ACTIVE anchor !== undefined && completionP === undefined +// +// Design principles: +// - Completion result fields (separatorMode, closedSet) are stored as-is +// from the backend response and never mutated as the user keeps typing. +// reuseSession() reads them to decide whether to show, hide, or re-fetch. +// - reuseSession() makes exactly four kinds of decisions: +// 1. Re-fetch — input has moved past what the current result covers +// 2. Show/update menu — input satisfies the result's constraints; +// trie filters the loaded completions against the typed prefix. +// 3. Hide menu, keep session — input is within the anchor but the +// result's constraints aren't satisfied yet (separator not typed, +// or no completions exist). A re-fetch would return the same result. +// 4. Uniquely satisfied — the user has exactly typed one completion +// entry (and it is not a prefix of any other). Gated by +// `commitMode`: +// commitMode="eager" → re-fetch immediately for the NEXT +// level's completions (e.g. variable-space grammar where +// tokens can abut without whitespace). +// commitMode="explicit" → suppress; the user hasn't committed +// yet (must type an explicit delimiter). B5 handles the +// separator arrival. +// `closedSet` is irrelevant here because it describes THIS +// level, not the next's. +// - The `closedSet` flag controls the no-match fallthrough: when the trie +// has zero matches for the typed prefix: +// closedSet=true → reuse (closed set, nothing else exists) +// closedSet=false → re-fetch (set is open, backend may know more) +// - The anchor is never advanced after a result is received. +// When `separatorMode` requires a separator, the separator is stripped +// from the raw prefix before being passed to the menu, so the trie +// still matches. +// +// This class has no DOM dependencies and is fully unit-testable with Jest. +export class PartialCompletionSession { + // The "anchor" prefix for the current session. Set to the full input + // when the request is issued, then narrowed to input[0..startIndex] when + // the backend reports how much the grammar consumed. `undefined` = IDLE. + private anchor: string | undefined = undefined; + + // Saved as-is from the last completion result. + private separatorMode: SeparatorMode = "space"; + private closedSet: boolean = false; + private commitMode: CommitMode = "explicit"; + + // The in-flight completion request, or undefined when settled. + private completionP: Promise | undefined; + + constructor( + private readonly menu: ISearchMenu, + private readonly dispatcher: ICompletionDispatcher, + ) {} + + // Main entry point. Called by PartialCompletion.update() after DOM checks pass. + // input: trimmed input text (ghost text stripped, leading whitespace stripped) + // getPosition: DOM callback that computes the menu anchor position; returns + // undefined when position cannot be determined (hides menu). + public update( + input: string, + getPosition: (prefix: string) => SearchMenuPosition | undefined, + ): void { + if (this.reuseSession(input, getPosition)) { + return; + } + + this.startNewSession(input, getPosition); + } + + // Hide the menu and cancel any in-flight fetch, but preserve session + // state so reuseSession() can still match the anchor if the user + // returns (e.g. cursor moved away then back without typing). + public hide(): void { + // Cancel any in-flight request but preserve anchor and config + // so reuseSession() can still match on re-focus. + this.completionP = undefined; + this.menu.hide(); + } + + // Reset state to IDLE without hiding the menu (used after handleSelect inserts text). + public resetToIdle(): void { + this.anchor = undefined; + this.completionP = undefined; + } + + // Returns the text typed after the anchor, or undefined when + // the input has diverged past the anchor or the separator is not yet present. + public getCompletionPrefix(input: string): string | undefined { + const anchor = this.anchor; + if (anchor === undefined || !input.startsWith(anchor)) { + return undefined; + } + const rawPrefix = input.substring(anchor.length); + const sepMode = this.separatorMode; + if (requiresSeparator(sepMode)) { + // The separator must be present and is not part of the replaceable prefix. + if (!separatorRegex(sepMode).test(rawPrefix)) { + return undefined; + } + return stripLeadingSeparator(rawPrefix, sepMode); + } + return rawPrefix; + } + + // Decides whether the current session can service `input` without a new + // backend fetch. Returns true to reuse, false to trigger a re-fetch. + // + // Decision order: + // PENDING — a fetch is in flight; wait for it (return true, no-op). + // RE-FETCH — input has moved outside the anchor; the saved result no + // longer applies (return false). + // HIDE+KEEP — input is within the anchor but the separator hasn't + // been typed yet; hide the menu but don't re-fetch + // (return true). + // UNIQUE — prefix exactly matches one entry and is not a prefix of + // any other; re-fetch for the NEXT level (return false). + // Gated by commitMode: "eager" re-fetches immediately; + // "explicit" defers to B5 (committed-past-boundary). + // SHOW — constraints satisfied; update the menu. The final + // return is `this.closedSet || this.menu.isActive()`: + // reuse when the trie still has matches, or when the set + // is closed (nothing new to fetch). Re-fetch only + // when the trie is empty AND the set is open. + // + // Re-fetch triggers (returns false → startNewSession): + // + // A. Session invalidation — anchor is stale; backend result was computed + // for a prefix that no longer matches the input. Unconditional. + // 1. No session — anchor is undefined (IDLE state). + // 2. Anchor diverged — input no longer starts with the saved anchor + // (e.g. backspace deleted into the anchor region). + // 3. Bad separator — separatorMode requires whitespace (or punctuation) + // immediately after anchor, but a non-separator + // character was typed instead. The constraint can + // never be satisfied, so treat as new input. + // + // B. Hierarchical navigation — user completed this level; re-fetch for + // the NEXT level's completions. closedSet describes THIS level, + // not the next. + // 4. Uniquely satisfied — typed prefix exactly matches one completion and + // is not a prefix of any other. Re-fetch for the + // NEXT level (e.g. agent name → subcommands). + // Gated by commitMode: when "explicit", this is + // suppressed (B5 handles it once the user types a + // separator). When "eager", fires immediately. + // 5. Committed past boundary — prefix contains a separator after a valid + // completion match (e.g. "set " where "set" matches + // but so does "setWindowState"). The user committed + // by typing a separator; re-fetch for next level. + // + // C. Open-set discovery — trie has zero matches and the set is not + // exhaustive; the backend may know about completions not yet loaded. + // Gated by closedSet === false. + // 6. Open set, no matches — trie has zero matches for the typed prefix + // AND closedSet is false. The backend may know about + // completions not yet loaded. + private reuseSession( + input: string, + getPosition: (prefix: string) => SearchMenuPosition | undefined, + ): boolean { + // [A1] No session — IDLE state, must fetch. + if (this.anchor === undefined) { + debug(`Partial completion re-fetch: no active session (IDLE)`); + return false; + } + + // PENDING — a fetch is already in flight. + if (this.completionP !== undefined) { + debug(`Partial completion pending: ${this.anchor}`); + return true; + } + + // ACTIVE from here. + const { anchor, separatorMode: sepMode, closedSet, commitMode } = this; + + // [A2] RE-FETCH — input moved past the anchor (e.g. backspace, new word). + if (!input.startsWith(anchor)) { + debug( + `Partial completion re-fetch: anchor diverged (anchor='${anchor}', input='${input}')`, + ); + return false; + } + + // Separator handling: the character immediately after the anchor must + // satisfy the separatorMode constraint. + // "space": whitespace required + // "spacePunctuation": whitespace or Unicode punctuation required + // "optional"/"none": no separator needed, fall through to SHOW + // + // Three sub-cases when a separator IS required: + // "" — separator not typed yet: HIDE+KEEP (separator may still arrive) + // " …" — separator present: SHOW (fall through, strip it below) + // "x…" — non-separator typed right after anchor: RE-FETCH (the + // separator constraint can never be satisfied without + // backtracking, so treat this as a new input) + const rawPrefix = input.substring(anchor.length); + const needsSep = requiresSeparator(sepMode); + if (needsSep) { + if (rawPrefix === "") { + debug( + `Partial completion deferred: still waiting for separator`, + ); + this.menu.hide(); + return true; // HIDE+KEEP + } + if (!separatorRegex(sepMode).test(rawPrefix)) { + // [A3] closedSet is not consulted here: it describes whether + // the completion *entries* are exhaustive, not whether + // the anchor token can extend. The grammar may parse + // the longer input on a completely different path. + debug( + `Partial completion re-fetch: non-separator after anchor (mode='${sepMode}', rawPrefix='${rawPrefix}')`, + ); + return false; // RE-FETCH (session invalidation) + } + } + + // SHOW — strip the leading separator (if any) before passing to the + // menu trie, so completions like "music" match prefix "" not " ". + const completionPrefix = needsSep + ? stripLeadingSeparator(rawPrefix, sepMode) + : rawPrefix; + + const position = getPosition(completionPrefix); + if (position !== undefined) { + debug( + `Partial completion update: '${completionPrefix}' @ ${JSON.stringify(position)}`, + ); + const uniquelySatisfied = this.menu.updatePrefix( + completionPrefix, + position, + ); + + // [B4] The user has typed text that exactly matches one + // completion and is not a prefix of any other. + // Only re-fetch when commitMode="eager" (tokens can abut + // without whitespace). When "explicit", B5 handles it + // once the user types a separator. + if (uniquelySatisfied) { + if (commitMode === "eager") { + debug( + `Partial completion re-fetch: '${completionPrefix}' uniquely satisfied (eager commit)`, + ); + return false; // RE-FETCH (hierarchical navigation) + } + debug( + `Partial completion: '${completionPrefix}' uniquely satisfied but commitMode='${commitMode}', deferring to separator`, + ); + return true; // REUSE — wait for explicit separator before re-fetching + } + + // [B5] Committed-past-boundary: the prefix contains whitespace + // or punctuation, meaning the user typed past a completion entry. + // If the text before the first separator exactly matches a + // completion, re-fetch for the next level. This handles the + // case where an entry (e.g. "set") is also a prefix of other + // entries ("setWindowState") so uniquelySatisfied is false, + // but the user committed by typing a separator. + const sepMatch = completionPrefix.match(/^(.+?)[\s\p{P}]/u); + if (sepMatch !== null && this.menu.hasExactMatch(sepMatch[1])) { + debug( + `Partial completion re-fetch: '${sepMatch[1]}' committed with separator`, + ); + return false; // RE-FETCH (hierarchical navigation) + } + } else { + debug( + `Partial completion: no position for prefix '${completionPrefix}', hiding menu`, + ); + this.menu.hide(); + } + + // [C6] When the menu is still active (trie has matches) we always + // reuse — the loaded completions are still useful. When there are + // NO matches, the decision depends on `closedSet`: + // closedSet=true → the set is closed; the user typed past all + // valid continuations, so re-fetching won't help. + // closedSet=false → the set is NOT closed; the user may have + // typed something valid that wasn't loaded, so + // re-fetch with the longer input (open-set discovery). + const active = this.menu.isActive(); + const reuse = closedSet || active; + debug( + `Partial completion ${reuse ? "reuse" : "re-fetch"}: closedSet=${closedSet}, menuActive=${active}`, + ); + return reuse; + } + + // Start a new completion session: issue backend request and process result. + private startNewSession( + input: string, + getPosition: (prefix: string) => SearchMenuPosition | undefined, + ): void { + debug(`Partial completion start: '${input}'`); + this.menu.hide(); + this.menu.setChoices([]); + this.anchor = input; + this.separatorMode = "space"; + this.closedSet = false; + this.commitMode = "explicit"; + const completionP = this.dispatcher.getCommandCompletion(input); + this.completionP = completionP; + completionP + .then((result) => { + if (this.completionP !== completionP) { + debug(`Partial completion canceled: '${input}'`); + return; + } + + this.completionP = undefined; + debug(`Partial completion result: `, result); + + this.separatorMode = result.separatorMode ?? "space"; + this.closedSet = result.closedSet; + this.commitMode = result.commitMode ?? "explicit"; + + const completions = toMenuItems(result.completions); + + if (completions.length === 0) { + debug( + `Partial completion skipped: No completions for '${input}'`, + ); + // Keep anchor at the full input so the anchor + // covers the entire typed text. The menu stays empty, + // so reuseSession()'s SHOW path will use `closedSet` to + // decide: closedSet=true → reuse (nothing more exists); + // closedSet=false → re-fetch when new input arrives. + // + // Override separatorMode: with no completions, there is + // nothing to separate from, so the separator check in + // reuseSession() should not interfere. + this.separatorMode = "none"; + return; + } + + // Anchor the session at the resolved prefix so + // subsequent keystrokes filter within the trie. + const partial = + result.startIndex >= 0 && result.startIndex <= input.length + ? input.substring(0, result.startIndex) + : input; + this.anchor = partial; + + this.menu.setChoices(completions); + + // Re-run update with captured input to show the menu (or defer + // if the separator has not been typed yet). + this.reuseSession(input, getPosition); + }) + .catch((e) => { + debugError(`Partial completion error: '${input}' ${e}`); + // On error, clear the in-flight promise but preserve the + // anchor so that identical input reuses the session (no + // re-fetch) while diverged input still triggers a new fetch. + this.completionP = undefined; + }); + } +} + +// ── Separator helpers ──────────────────────────────────────────────────────── + +function requiresSeparator(mode: SeparatorMode): boolean { + return mode === "space" || mode === "spacePunctuation"; +} + +function separatorRegex(mode: SeparatorMode): RegExp { + return mode === "space" ? /^\s/ : /^[\s\p{P}]/u; +} + +// Strip leading separator characters from rawPrefix. +// For "space" mode, only whitespace is stripped. +// For "spacePunctuation" mode, leading whitespace and punctuation are stripped. +function stripLeadingSeparator(rawPrefix: string, mode: SeparatorMode): string { + return mode === "space" + ? rawPrefix.trimStart() + : rawPrefix.replace(/^[\s\p{P}]+/u, ""); +} + +// Convert backend CompletionGroups into flat SearchMenuItems, +// preserving group order and sorting within each group. +function toMenuItems(groups: CompletionGroup[]): SearchMenuItem[] { + const items: SearchMenuItem[] = []; + let sortIndex = 0; + for (const group of groups) { + const sorted = group.sorted + ? group.completions + : [...group.completions].sort(); + for (const choice of sorted) { + items.push({ + matchText: choice, + selectedText: choice, + sortIndex: sortIndex++, + ...(group.needQuotes !== undefined + ? { needQuotes: group.needQuotes } + : {}), + ...(group.emojiChar !== undefined + ? { emojiChar: group.emojiChar } + : {}), + }); + } + } + return items; +} diff --git a/ts/packages/shell/src/renderer/src/prefixTree.ts b/ts/packages/shell/src/renderer/src/prefixTree.ts index 410e4b5df..42f6df81a 100644 --- a/ts/packages/shell/src/renderer/src/prefixTree.ts +++ b/ts/packages/shell/src/renderer/src/prefixTree.ts @@ -6,9 +6,9 @@ export class TSTNode { this.count = 0; } count: number; - left?: TSTNode; - middle?: TSTNode; - right?: TSTNode; + left: TSTNode | undefined; + middle: TSTNode | undefined; + right: TSTNode | undefined; data: TData | undefined; } diff --git a/ts/packages/shell/src/renderer/src/search.ts b/ts/packages/shell/src/renderer/src/search.ts index 214605813..91e02f50c 100644 --- a/ts/packages/shell/src/renderer/src/search.ts +++ b/ts/packages/shell/src/renderer/src/search.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { TST } from "./prefixTree"; +import { SearchMenuBase } from "./searchMenuBase"; import { InlineSearchMenuUI } from "./searchMenuUI/inlineSearchMenuUI"; import { LocalSearchMenuUI } from "./searchMenuUI/localSearchMenuUI"; import { @@ -10,80 +10,38 @@ import { SearchMenuUI, } from "./searchMenuUI/searchMenuUI"; -function normalizeMatchText(text: string): string { - // Remove diacritical marks, and case replace any space characters with the normalized ' '. - return text - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") // Remove combining diacritical marks - .replace(/\s/g, " ") - .toLowerCase(); -} - -export class SearchMenu { +export class SearchMenu extends SearchMenuBase { private searchMenuUI: SearchMenuUI | undefined; - private trie: TST = new TST(); - private prefix: string | undefined; constructor( private readonly onCompletion: (item: SearchMenuItem) => void, private readonly inline: boolean, private readonly textEntry?: HTMLSpanElement, - ) {} - - public isActive() { - return this.searchMenuUI !== undefined; + ) { + super(); } - public setChoices(choices: SearchMenuItem[]) { - this.prefix = undefined; - this.trie.init(); - for (const choice of choices) { - // choices are sorted in priority order so prefer first norm text - const normText = normalizeMatchText(choice.matchText); - if (!this.trie.get(normText)) { - this.trie.insert(normText, choice); - } + protected override onShow( + position: SearchMenuPosition, + prefix: string, + items: SearchMenuItem[], + ): void { + if (this.searchMenuUI === undefined) { + this.searchMenuUI = this.inline + ? new InlineSearchMenuUI(this.onCompletion, this.textEntry!) + : new LocalSearchMenuUI(this.onCompletion); } + this.searchMenuUI.update({ position, prefix, items }); } - public numChoices() { - return this.trie.size(); + protected override onUpdatePosition(position: SearchMenuPosition): void { + this.searchMenuUI!.update({ position }); } - public updatePrefix(prefix: string, position: SearchMenuPosition) { - if (this.numChoices() === 0) { - return; - } - - if (this.prefix === prefix && this.searchMenuUI !== undefined) { - // No need to update existing searchMenuUI, just update the position. - this.searchMenuUI.update({ position }); - return; - } - - this.prefix = prefix; - const items = this.trie.dataWithPrefix(normalizeMatchText(prefix)); - const showMenu = - items.length !== 0 && - (items.length !== 1 || items[0].matchText !== prefix); - - if (showMenu) { - if (this.searchMenuUI === undefined) { - this.searchMenuUI = this.inline - ? new InlineSearchMenuUI(this.onCompletion, this.textEntry!) - : new LocalSearchMenuUI(this.onCompletion); - } - this.searchMenuUI.update({ position, prefix, items }); - } else { - this.hide(); - } + protected override onHide(): void { + this.searchMenuUI!.close(); + this.searchMenuUI = undefined; } - public hide() { - if (this.searchMenuUI) { - this.searchMenuUI.close(); - this.searchMenuUI = undefined; - } - } public handleMouseWheel(deltaY: number) { this.searchMenuUI?.adjustSelection(deltaY); } diff --git a/ts/packages/shell/src/renderer/src/searchMenuBase.ts b/ts/packages/shell/src/renderer/src/searchMenuBase.ts new file mode 100644 index 000000000..82060bff7 --- /dev/null +++ b/ts/packages/shell/src/renderer/src/searchMenuBase.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TST } from "./prefixTree.js"; +import { + SearchMenuItem, + SearchMenuPosition, +} from "../../preload/electronTypes.js"; + +export function normalizeMatchText(text: string): string { + // Remove diacritical marks, and replace any space characters with normalized ' '. + return text + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") // Remove combining diacritical marks + .replace(/\s/g, " ") + .toLowerCase(); +} + +export class SearchMenuBase { + private trie: TST = new TST(); + private prefix: string | undefined; + private _active: boolean = false; + + public setChoices(choices: SearchMenuItem[]): void { + this.prefix = undefined; + this.trie.init(); + for (const choice of choices) { + // choices are sorted in priority order so prefer first norm text + const normText = normalizeMatchText(choice.matchText); + if (!this.trie.get(normText)) { + this.trie.insert(normText, choice); + } + } + } + + public numChoices(): number { + return this.trie.size(); + } + + public hasExactMatch(text: string): boolean { + return this.trie.contains(normalizeMatchText(text)); + } + + public updatePrefix(prefix: string, position: SearchMenuPosition): boolean { + if (this.trie.size() === 0) { + return false; + } + + if (this.prefix === prefix && this._active) { + this.onUpdatePosition(position); + return false; + } + + this.prefix = prefix; + const items = this.trie.dataWithPrefix(normalizeMatchText(prefix)); + const uniquelySatisfied = + items.length === 1 && + normalizeMatchText(items[0].matchText) === + normalizeMatchText(prefix); + const showMenu = items.length !== 0 && !uniquelySatisfied; + + if (showMenu) { + this._active = true; + this.onShow(position, prefix, items); + } else { + this.hide(); + } + return uniquelySatisfied; + } + + public hide(): void { + if (this._active) { + this._active = false; + this.onHide(); + } + } + + public isActive(): boolean { + return this._active; + } + + protected onShow( + _position: SearchMenuPosition, + _prefix: string, + _items: SearchMenuItem[], + ): void {} + + protected onUpdatePosition(_position: SearchMenuPosition): void {} + + protected onHide(): void {} +} diff --git a/ts/packages/shell/src/renderer/src/setContent.ts b/ts/packages/shell/src/renderer/src/setContent.ts index 21441dea5..baa95b251 100644 --- a/ts/packages/shell/src/renderer/src/setContent.ts +++ b/ts/packages/shell/src/renderer/src/setContent.ts @@ -8,8 +8,8 @@ import { DisplayType, DisplayMessageKind, MessageContent, - getContentForType, } from "@typeagent/agent-sdk"; +import { getContentForType } from "@typeagent/agent-sdk/helpers/display"; import DOMPurify from "dompurify"; import { SettingsView } from "./settingsView"; import MarkdownIt from "markdown-it"; diff --git a/ts/packages/shell/src/tsconfig.json b/ts/packages/shell/src/tsconfig.json new file mode 100644 index 000000000..d4adab848 --- /dev/null +++ b/ts/packages/shell/src/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "outDir": "../dist/src", + "rootDir": ".", + "moduleResolution": "node16" + }, + "include": [ + "renderer/src/partialCompletionSession.ts", + "renderer/src/prefixTree.ts", + "renderer/src/searchMenuBase.ts", + "preload/electronTypes.ts", + "preload/shellSettingsType.ts" + ] +} diff --git a/ts/packages/shell/test/partialCompletion/commitMode.spec.ts b/ts/packages/shell/test/partialCompletion/commitMode.spec.ts new file mode 100644 index 000000000..acffed51b --- /dev/null +++ b/ts/packages/shell/test/partialCompletion/commitMode.spec.ts @@ -0,0 +1,243 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + PartialCompletionSession, + makeMenu, + makeDispatcher, + makeCompletionResult, + getPos, +} from "./helpers.js"; + +// ── commitMode ──────────────────────────────────────────────────────────────── + +describe("PartialCompletionSession — commitMode", () => { + test("commitMode=explicit (default): uniquely satisfied does NOT re-fetch", async () => { + const menu = makeMenu(); + // Default commitMode (omitted → "explicit") + const result = makeCompletionResult(["song"], 4, { + separatorMode: "space", + closedSet: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + session.update("play song", getPos); + + // "song" uniquely matched, but commitMode="explicit" — B4 suppressed + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("commitMode=explicit: uniquely satisfied + trailing space triggers re-fetch via B5", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + separatorMode: "space", + closedSet: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // First: "play song" — uniquely satisfied but suppressed (no trailing space) + session.update("play song", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + // Second: "play song " — user typed space → B5 fires (committed past boundary) + session.update("play song ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play song ", + ); + }); + + test("commitMode=eager: uniquely satisfied triggers immediate re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + separatorMode: "space", + commitMode: "eager", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + session.update("play song", getPos); + + // commitMode="eager" — B4 fires immediately + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play song", + ); + }); + + test("commitMode=explicit: B5 committed-past-boundary still fires", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["set", "setWindowState"], 4, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + // "set " — contains separator after exact match → B5 fires + session.update("play set ", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play set ", + ); + }); + + test("commitMode=explicit: open-set no-matches still triggers re-fetch (C6 unaffected)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + separatorMode: "space", + closedSet: false, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + // "xyz" — no trie match, closedSet=false → C6 re-fetch + session.update("play xyz", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("commitMode defaults to explicit when omitted from result", async () => { + const menu = makeMenu(); + // No commitMode in result — defaults to "explicit" + const result = makeCompletionResult(["song"], 4, { + separatorMode: "space", + closedSet: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + session.update("play song", getPos); + + // Default commitMode="explicit" — B4 suppressed + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("commitMode=explicit + closedSet=false: uniquely satisfied does NOT re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + separatorMode: "space", + closedSet: false, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + // "song" uniquely matches — commitMode="explicit" must suppress re-fetch + // even though closedSet=false (closedSet describes THIS level, not next) + session.update("play song", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + // Only after typing a separator should B5 trigger a re-fetch + session.update("play song ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); +}); + +// ── committed-past-boundary (hasExactMatch) ─────────────────────────────────── + +describe("PartialCompletionSession — committed-past-boundary re-fetch", () => { + test("closedSet=true: typing space after exact match triggers re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult( + ["set", "setWindowState", "setWindowZoomLevel"], + 4, + { separatorMode: "space", closedSet: true }, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // User types "set " — prefix is "set ", exact match "set" + separator + session.update("play set ", getPos); + + expect(menu.hasExactMatch).toHaveBeenCalledWith("set"); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play set ", + ); + }); + + test("closedSet=true: typing multiple spaces after exact match triggers re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult( + ["set", "setWindowState", "setWindowZoomLevel"], + 4, + { separatorMode: "space", closedSet: true }, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + // Double space after "set" + session.update("play set ", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("closedSet=true: typing punctuation after exact match triggers re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult( + ["set", "setWindowState", "setWindowZoomLevel"], + 4, + { separatorMode: "space", closedSet: true }, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + // Punctuation after "set" + session.update("play set.", getPos); + + expect(menu.hasExactMatch).toHaveBeenCalledWith("set"); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("closedSet=true: typing separator after non-matching text does NOT re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult( + ["set", "setWindowState", "setWindowZoomLevel"], + 4, + { separatorMode: "space", closedSet: true }, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + // "xyz" is not a known completion — closedSet=true should suppress re-fetch + session.update("play xyz ", getPos); + + expect(menu.hasExactMatch).toHaveBeenCalledWith("xyz"); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts b/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts new file mode 100644 index 000000000..8e357f610 --- /dev/null +++ b/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import { + PartialCompletionSession, + ICompletionDispatcher, + CommandCompletionResult, + makeMenu, + makeCompletionResult, + getPos, +} from "./helpers.js"; + +describe("PartialCompletionSession — backend error handling", () => { + test("rejected promise clears PENDING state so next update can proceed", async () => { + const menu = makeMenu(); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockRejectedValueOnce(new Error("network error")) + .mockResolvedValue(makeCompletionResult(["song"], 4)), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + // Flush rejected promise + catch handler + await Promise.resolve(); + await Promise.resolve(); + + // After rejection, anchor is still "play" with separatorMode="space". + // Diverged input triggers a re-fetch (anchor no longer matches). + session.update("stop", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "stop", + ); + }); + + test("rejected promise: same input within anchor does not re-fetch", async () => { + const menu = makeMenu(); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockRejectedValueOnce(new Error("network error")) + .mockResolvedValue(makeCompletionResult(["song"], 4)), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + await Promise.resolve(); + + // Same input — anchor still matches, reuse session (no re-fetch) + session.update("play", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("rejected promise does not leave session stuck in PENDING", async () => { + const menu = makeMenu(); + let rejectFn!: (e: Error) => void; + const rejecting = new Promise( + (_, reject) => (rejectFn = reject), + ); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockReturnValueOnce(rejecting) + .mockResolvedValue(makeCompletionResult(["song"], 4)), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + + // While PENDING, second update is suppressed + session.update("play more", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + // Now reject + rejectFn(new Error("timeout")); + await Promise.resolve(); + await Promise.resolve(); + + // Session is no longer PENDING — diverged input triggers re-fetch + session.update("stop", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("rejected promise does not populate menu", async () => { + const menu = makeMenu(); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockRejectedValue(new Error("timeout")), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + await Promise.resolve(); + + // setChoices should only have the initial empty-array call, not real items + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.setChoices).toHaveBeenCalledWith([]); + }); +}); diff --git a/ts/packages/shell/test/partialCompletion/helpers.ts b/ts/packages/shell/test/partialCompletion/helpers.ts new file mode 100644 index 000000000..1b61fcad8 --- /dev/null +++ b/ts/packages/shell/test/partialCompletion/helpers.ts @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest, type jest as JestTypes } from "@jest/globals"; +import { + ICompletionDispatcher, + ISearchMenu, + PartialCompletionSession, +} from "../../src/renderer/src/partialCompletionSession.js"; +import { SearchMenuPosition } from "../../src/preload/electronTypes.js"; +import { CompletionGroup } from "@typeagent/agent-sdk"; +import { CommandCompletionResult } from "agent-dispatcher"; +import { SearchMenuBase } from "../../src/renderer/src/searchMenuBase.js"; + +export { PartialCompletionSession }; +export type { ICompletionDispatcher, ISearchMenu }; +export type { CompletionGroup }; +export type { CommandCompletionResult }; +export type { SearchMenuPosition }; + +type Mocked any> = T & + JestTypes.MockedFunction; + +// Real trie-backed ISearchMenu backed by SearchMenuBase. +// Every method is a jest.fn() wrapping the real implementation so tests can +// assert on call counts and arguments. +export class TestSearchMenu extends SearchMenuBase { + override setChoices: Mocked = jest.fn( + (...args: Parameters) => + super.setChoices(...args), + ) as any; + + override updatePrefix: Mocked = jest.fn( + (prefix: string, position: SearchMenuPosition): boolean => + super.updatePrefix(prefix, position), + ) as any; + + override hasExactMatch: Mocked = jest.fn( + (text: string): boolean => super.hasExactMatch(text), + ) as any; + + override hide: Mocked = jest.fn(() => + super.hide(), + ) as any; + + override isActive: Mocked = jest.fn(() => + super.isActive(), + ) as any; +} + +export function makeMenu(): TestSearchMenu { + return new TestSearchMenu(); +} + +export type MockDispatcher = { + getCommandCompletion: jest.MockedFunction< + ICompletionDispatcher["getCommandCompletion"] + >; +}; + +export function makeDispatcher( + result: CommandCompletionResult = { + startIndex: 0, + completions: [], + separatorMode: undefined, + closedSet: true, + }, +): MockDispatcher { + return { + getCommandCompletion: jest + .fn() + .mockResolvedValue(result), + }; +} + +export const anyPosition: SearchMenuPosition = { left: 0, bottom: 0 }; +export const getPos = (_prefix: string) => anyPosition; + +export function makeCompletionResult( + completions: string[], + startIndex: number = 0, + opts: Partial = {}, +): CommandCompletionResult { + const group: CompletionGroup = { name: "test", completions }; + return { + startIndex, + completions: [group], + closedSet: false, + ...opts, + }; +} diff --git a/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts b/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts new file mode 100644 index 000000000..63a547df7 --- /dev/null +++ b/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts @@ -0,0 +1,275 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import { + PartialCompletionSession, + ICompletionDispatcher, + makeMenu, + makeDispatcher, + makeCompletionResult, + getPos, + anyPosition, +} from "./helpers.js"; + +// ── getCompletionPrefix ─────────────────────────────────────────────────────── + +describe("PartialCompletionSession — getCompletionPrefix", () => { + test("returns undefined when session is IDLE", () => { + const session = new PartialCompletionSession( + makeMenu(), + makeDispatcher(), + ); + expect(session.getCompletionPrefix("anything")).toBeUndefined(); + }); + + test("returns suffix after anchor when input starts with anchor", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4); + const session = new PartialCompletionSession( + menu, + makeDispatcher(result), + ); + + session.update("play song", getPos); + await Promise.resolve(); + + expect(session.getCompletionPrefix("play song")).toBe("song"); + }); + + test("returns undefined when input diverges from anchor", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4); + const session = new PartialCompletionSession( + menu, + makeDispatcher(result), + ); + + session.update("play song", getPos); + await Promise.resolve(); + + // Input no longer starts with anchor "play" + expect(session.getCompletionPrefix("stop")).toBeUndefined(); + }); + + test("separatorMode: returns stripped prefix when separator is present", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "space", + }); + const session = new PartialCompletionSession( + menu, + makeDispatcher(result), + ); + + session.update("play", getPos); + await Promise.resolve(); + + // Separator + typed text: prefix should be "mu" (space stripped) + expect(session.getCompletionPrefix("play mu")).toBe("mu"); + }); + + test("separatorMode: returns undefined when separator is absent", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "space", + }); + const session = new PartialCompletionSession( + menu, + makeDispatcher(result), + ); + + session.update("play", getPos); + await Promise.resolve(); + + // No separator yet — undefined means no replacement should happen + expect(session.getCompletionPrefix("play")).toBeUndefined(); + }); +}); + +// ── resetToIdle ─────────────────────────────────────────────────────────────── + +describe("PartialCompletionSession — resetToIdle", () => { + test("clears session so next update re-fetches", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(makeCompletionResult(["song"], 4)); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play song", getPos); + await Promise.resolve(); // → ACTIVE + + session.resetToIdle(); + + // After reset, next update should fetch fresh completions + session.update("play song", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("does not hide the menu (caller is responsible for that)", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(makeCompletionResult(["song"], 4)); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play song", getPos); + await Promise.resolve(); + + menu.hide.mockClear(); + session.resetToIdle(); + + expect(menu.hide).not.toHaveBeenCalled(); + }); +}); + +// ── @-command routing ───────────────────────────────────────────────────────── + +describe("PartialCompletionSession — @command routing", () => { + test("@ command with trailing space fetches full input", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("@config ", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "@config ", + ); + }); + + test("@ command with partial word fetches full input (backend filters)", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("@config c", getPos); + + // Backend receives full input and returns completions with the + // correct startIndex; no word-boundary truncation needed. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "@config c", + ); + }); + + test("@ command with no space fetches full input", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("@config", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith("@config"); + }); + + test("@ command in PENDING state does not re-fetch", () => { + const menu = makeMenu(); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockReturnValue(new Promise(() => {})), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("@config ", getPos); + session.update("@config c", getPos); // same anchor: "@config " — PENDING reuse + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("@ command: separatorMode defers menu until space typed", async () => { + const menu = makeMenu(); + // Backend returns subcommands with separatorMode: "space" + // (anchor = "@config", subcommands follow after a space) + const result = makeCompletionResult(["clear", "theme"], 7, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // User types "@config" → completions loaded, menu deferred (no separator yet) + session.update("@config", getPos); + await Promise.resolve(); + + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ selectedText: "clear" }), + ]), + ); + expect(menu.updatePrefix).not.toHaveBeenCalled(); + + // User types space → separator present, menu appears + session.update("@config ", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + // No re-fetch — same session handles both states + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("@ command: typing after space filters within same session", async () => { + const menu = makeMenu(); + // Backend: separatorMode, anchor = "@config" + const result = makeCompletionResult(["clear", "theme"], 7, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("@config", getPos); + await Promise.resolve(); + + // Type space + partial subcommand + session.update("@config cl", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("cl", anyPosition); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("@ command: empty result (closedSet=true) suppresses re-fetch", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("@unknown", getPos); + await Promise.resolve(); // → empty completions, closedSet=true + + // Still within anchor — no re-fetch + session.update("@unknownmore", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("@ command: backspace past anchor after empty result triggers re-fetch", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("@unknown", getPos); + await Promise.resolve(); // → empty completions with current="@unknown" + + // Backspace past anchor + session.update("@unknow", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "@unknow", + ); + }); +}); + +// ── miscellaneous ───────────────────────────────────────────────────────────── + +describe("PartialCompletionSession — miscellaneous", () => { + test("getPosition returning undefined hides the menu", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play song", getPos); + await Promise.resolve(); + + menu.hide.mockClear(); + // getPosition returns undefined (e.g. caret not found) + session.update("play song", () => undefined); + + expect(menu.hide).toHaveBeenCalled(); + }); +}); diff --git a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts new file mode 100644 index 000000000..8cd3c3000 --- /dev/null +++ b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts @@ -0,0 +1,282 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + PartialCompletionSession, + CommandCompletionResult, + CompletionGroup, + makeMenu, + makeDispatcher, + makeCompletionResult, + getPos, + anyPosition, +} from "./helpers.js"; + +describe("PartialCompletionSession — result processing", () => { + test("startIndex narrows the anchor (current) to input[0..startIndex]", async () => { + const menu = makeMenu(); + // startIndex=4 means grammar consumed "play" (4 chars); the + // trailing space is the separator between anchor and completions. + const result = makeCompletionResult(["song", "shuffle"], 4); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play song", getPos); + await Promise.resolve(); + + // prefix should be "song" (the text after anchor "play" + separator " ") + expect(menu.updatePrefix).toHaveBeenCalledWith("song", anyPosition); + }); + + test("group order preserved: items appear in backend-provided group order", async () => { + const menu = makeMenu(); + const group1: CompletionGroup = { + name: "grammar", + completions: ["by"], + sorted: true, + }; + const group2: CompletionGroup = { + name: "entities", + completions: ["Bohemian Rhapsody"], + sorted: true, + }; + const result: CommandCompletionResult = { + startIndex: 4, + completions: [group1, group2], + closedSet: false, + }; + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + const calls = menu.setChoices.mock.calls; + const items = calls[calls.length - 1][0] as { + sortIndex: number; + selectedText: string; + }[]; + const byIndex = items.find((i) => i.selectedText === "by")!.sortIndex; + const bohIndex = items.find( + (i) => i.selectedText === "Bohemian Rhapsody", + )!.sortIndex; + expect(byIndex).toBeLessThan(bohIndex); + }); + + test("needQuotes propagated from group to each SearchMenuItem", async () => { + const menu = makeMenu(); + const group: CompletionGroup = { + name: "entities", + completions: ["Bohemian Rhapsody"], + needQuotes: true, + sorted: true, + }; + const result: CommandCompletionResult = { + startIndex: 4, + completions: [group], + closedSet: false, + }; + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + selectedText: "Bohemian Rhapsody", + needQuotes: true, + }), + ]), + ); + }); + + test("unsorted group items are sorted alphabetically", async () => { + const menu = makeMenu(); + const group: CompletionGroup = { + name: "test", + completions: ["zebra", "apple", "mango"], + sorted: false, + }; + const result: CommandCompletionResult = { + startIndex: 0, + completions: [group], + closedSet: false, + separatorMode: "none", + }; + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("x", getPos); + await Promise.resolve(); + + const calls = menu.setChoices.mock.calls; + const items = calls[calls.length - 1][0] as { selectedText: string }[]; + const texts = items.map((i) => i.selectedText); + expect(texts).toEqual(["apple", "mango", "zebra"]); + }); + + test("empty completions list does not call setChoices with items", async () => { + const menu = makeMenu(); + const result: CommandCompletionResult = { + startIndex: 0, + completions: [{ name: "empty", completions: [] }], + closedSet: false, + }; + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Only the initial setChoices([]) call (cancel) should have been made + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.setChoices).toHaveBeenCalledWith([]); + }); + + test("emojiChar from group is propagated to each SearchMenuItem", async () => { + const menu = makeMenu(); + const group: CompletionGroup = { + name: "agents", + completions: ["player", "calendar"], + emojiChar: "\uD83C\uDFB5", + sorted: true, + }; + const result: CommandCompletionResult = { + startIndex: 0, + completions: [group], + closedSet: false, + separatorMode: "none", + }; + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + selectedText: "player", + emojiChar: "\uD83C\uDFB5", + }), + expect.objectContaining({ + selectedText: "calendar", + emojiChar: "\uD83C\uDFB5", + }), + ]), + ); + }); + + test("emojiChar absent from group means no emojiChar on items", async () => { + const menu = makeMenu(); + const group: CompletionGroup = { + name: "plain", + completions: ["alpha"], + sorted: true, + }; + const result: CommandCompletionResult = { + startIndex: 0, + completions: [group], + closedSet: false, + separatorMode: "none", + }; + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + const calls = menu.setChoices.mock.calls; + const items = calls[calls.length - 1][0] as Record[]; + expect(items[0]).not.toHaveProperty("emojiChar"); + }); + + test("sorted group preserves order while unsorted group is alphabetized", async () => { + const menu = makeMenu(); + const sortedGroup: CompletionGroup = { + name: "grammar", + completions: ["zebra", "apple"], + sorted: true, + }; + const unsortedGroup: CompletionGroup = { + name: "entities", + completions: ["cherry", "banana"], + sorted: false, + }; + const result: CommandCompletionResult = { + startIndex: 0, + completions: [sortedGroup, unsortedGroup], + closedSet: false, + separatorMode: "none", + }; + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("x", getPos); + await Promise.resolve(); + + const calls = menu.setChoices.mock.calls; + const items = calls[calls.length - 1][0] as { + selectedText: string; + sortIndex: number; + }[]; + const texts = items.map((i) => i.selectedText); + + // Sorted group: order preserved (zebra before apple) + // Unsorted group: alphabetized (banana before cherry) + // Cross-group: sorted group first + expect(texts).toEqual(["zebra", "apple", "banana", "cherry"]); + + // sortIndex is sequential across both groups + expect(items.map((i) => i.sortIndex)).toEqual([0, 1, 2, 3]); + }); + + test("negative startIndex falls back to full input as anchor", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], -1, { + separatorMode: "none", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Anchor is "play" (full input). rawPrefix="" → updatePrefix("", ...) + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + }); + + test("startIndex=0 sets empty anchor", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["play"], 0, { + separatorMode: "none", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Anchor is "" (empty). rawPrefix="play" → updatePrefix("play", ...) + expect(menu.updatePrefix).toHaveBeenCalledWith("play", anyPosition); + }); + + test("startIndex beyond input length falls back to full input as anchor", async () => { + const menu = makeMenu(); + // startIndex=99 is beyond "play" (length 4) — anchor falls back to "play" + const result = makeCompletionResult(["song"], 99, { + separatorMode: "none", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Anchor is "play" (full input). reuseSession is called with the captured + // input "play", so rawPrefix="" and updatePrefix is called with "". + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + }); +}); diff --git a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts new file mode 100644 index 000000000..d859e273f --- /dev/null +++ b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + PartialCompletionSession, + makeMenu, + makeDispatcher, + makeCompletionResult, + getPos, + anyPosition, +} from "./helpers.js"; + +// ── separatorMode: "space" ──────────────────────────────────────────────────── + +describe("PartialCompletionSession — separatorMode: space", () => { + test("defers menu display until trailing space is present", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // Input without trailing space: "play" — choices are loaded but menu is not shown + session.update("play", getPos); + await Promise.resolve(); + + // setChoices IS called with actual items (trie is populated for later) + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ selectedText: "music" }), + ]), + ); + // But updatePrefix is NOT called yet (menu not shown) + expect(menu.updatePrefix).not.toHaveBeenCalled(); + }); + + test("typing separator shows menu without re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // First update: "play" — deferred (separatorMode, no trailing space) + session.update("play", getPos); + await Promise.resolve(); + + // Second update: "play " — separator typed, menu should appear + session.update("play ", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + // No re-fetch — same dispatcher call count + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("menu shown after trailing space is typed", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: undefined, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); + + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ selectedText: "music" }), + ]), + ); + }); +}); + +// ── separatorMode: "spacePunctuation" ───────────────────────────────────────── + +describe("PartialCompletionSession — separatorMode: spacePunctuation", () => { + test("space satisfies spacePunctuation separator", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Space satisfies spacePunctuation + session.update("play ", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("punctuation satisfies spacePunctuation separator", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Punctuation mark satisfies spacePunctuation. + // The leading punctuation separator is stripped, just like whitespace. + session.update("play.mu", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("mu", anyPosition); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("letter after anchor triggers re-fetch under spacePunctuation", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // A letter is neither space nor punctuation — triggers re-fetch (A3) + session.update("playx", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "playx", + ); + }); + + test("no separator yet hides menu under spacePunctuation", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Exact anchor, no separator — menu hidden but session kept + menu.hide.mockClear(); + session.update("play", getPos); + + expect(menu.hide).toHaveBeenCalled(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); + +// ── separatorMode: "optional" ───────────────────────────────────────────────── + +describe("PartialCompletionSession — separatorMode: optional", () => { + test("completions shown immediately without separator", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "optional", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // "optional" does not require a separator — menu shown immediately + // rawPrefix="" → updatePrefix("", ...) + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + }); + + test("typing after anchor filters within session", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music", "movie"], 4, { + separatorMode: "optional", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + session.update("playmu", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("mu", anyPosition); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); + +// ── separatorMode edge cases ───────────────────────────────────────────────── + +describe("PartialCompletionSession — separatorMode edge cases", () => { + test("re-update with same input before separator does not re-fetch", async () => { + // Regression: selectionchange can fire again with the same input while + // the session is waiting for a separator. Must not trigger a re-fetch. + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); // deferred — waiting for separator + + session.update("play", getPos); // same input again (e.g. selectionchange) + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("input diverges before separator arrives triggers re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); // deferred + + // User typed a non-space character instead of a separator + session.update("play2", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play2", + ); + }); + + test("separator already in input when result arrives shows menu immediately", async () => { + // User typed "play " fast enough that the promise resolves after the space. + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "space", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // Fetch was issued for "play" but by the time it resolves the user + // has already moved on; a second update for "play " is already active. + // Simulate by updating to "play " *before* awaiting. + session.update("play", getPos); + // (promise not yet resolved — we rely on the .then() calling reuseSession + // with the captured "play" input, which has no separator, so menu stays + // hidden. A subsequent update("play ", ...) then shows it.) + await Promise.resolve(); + + session.update("play ", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts new file mode 100644 index 000000000..33eadc863 --- /dev/null +++ b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts @@ -0,0 +1,304 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest } from "@jest/globals"; +import { + PartialCompletionSession, + ICompletionDispatcher, + CommandCompletionResult, + makeMenu, + makeDispatcher, + makeCompletionResult, + getPos, + anyPosition, +} from "./helpers.js"; + +describe("PartialCompletionSession — state transitions", () => { + test("IDLE → PENDING: first update triggers a backend fetch", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith("play"); + }); + + test("PENDING: second update while promise is in-flight does not re-fetch", () => { + const menu = makeMenu(); + // Never-resolving promise keeps session in PENDING + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockReturnValue(new Promise(() => {})), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + session.update("play s", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("PENDING → ACTIVE: completions returned → setChoices + updatePrefix called", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song", "shuffle"], 4); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // flush microtask + + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ selectedText: "shuffle" }), + expect.objectContaining({ selectedText: "song" }), + ]), + ); + expect(menu.updatePrefix).toHaveBeenCalled(); + }); + + test("PENDING → ACTIVE: empty result (closedSet=true) suppresses re-fetch while input has same prefix", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Empty completions + closedSet=true — no new fetch even with extended input + session.update("play s", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("empty result: backspace past anchor triggers a new fetch", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); // → ACTIVE (empty, closedSet=true) with current="play" + + // Backspace past anchor + session.update("pla", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith("pla"); + }); + + test("ACTIVE → hide+keep: closedSet=true, trie has no matches — no re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + closedSet: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // User types more; trie returns no matches but input is within anchor. + // closedSet=true → exhaustive set, no point re-fetching. + session.update("play xyz", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(menu.hide).toHaveBeenCalled(); + }); + + test("ACTIVE → re-fetch: closedSet=false, trie has no matches — re-fetches", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4); // closedSet=false default + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // User types text with no trie match. closedSet=false → set is NOT + // exhaustive, so we should re-fetch in case the backend knows more. + session.update("play xyz", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play xyz", + ); + }); + + test("ACTIVE → backspace restores menu after no-match without re-fetch (closedSet=true)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song", "shuffle"], 4, { + closedSet: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // User types non-matching text. closedSet=true → no re-fetch. + session.update("play xyz", getPos); + expect(menu.hide).toHaveBeenCalled(); + + // User backspaces to matching prefix — menu reappears without re-fetch + menu.updatePrefix.mockClear(); + session.update("play so", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(menu.updatePrefix).toHaveBeenCalledWith("so", anyPosition); + }); + + test("hide() preserves anchor so same input reuses session", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(makeCompletionResult(["song"], 4)); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + session.hide(); + expect(menu.hide).toHaveBeenCalled(); + + // After hide, same input within anchor reuses session — no re-fetch + session.update("play", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("hide() preserves anchor: diverged input triggers re-fetch", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(makeCompletionResult(["song"], 4)); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + session.hide(); + + // Input that diverges from anchor triggers a new fetch + session.update("stop", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "stop", + ); + }); + + test("hide() cancels an in-flight request (stale result is ignored)", async () => { + const menu = makeMenu(); + let resolve!: (v: CommandCompletionResult) => void; + const pending = new Promise( + (r) => (resolve = r), + ); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockReturnValue(pending), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + session.hide(); // cancels the promise + + // Now resolve the stale promise — should be a no-op + resolve(makeCompletionResult(["song"], 4)); + await Promise.resolve(); + + expect(menu.setChoices).not.toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ selectedText: "song" }), + ]), + ); + }); + + test("empty input fetches completions from backend", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["@"], 0, { + separatorMode: "none", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith(""); + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ selectedText: "@" }), + ]), + ); + }); + + test("empty input: second update reuses session without re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["@"], 0, { + separatorMode: "none", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + session.update("", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("empty input: unique match triggers re-fetch (commitMode=eager)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["@"], 0, { + separatorMode: "none", + commitMode: "eager", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + session.update("@", getPos); + + // "@" uniquely matches the only completion — triggers re-fetch + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith("@"); + }); + + test("empty input: unique match triggers re-fetch even when closedSet=true (commitMode=eager)", async () => { + const menu = makeMenu(); + // closedSet=true means exhaustive at THIS level, but uniquelySatisfied + // means the user needs NEXT level completions — always re-fetch. + const result = makeCompletionResult(["@"], 0, { + closedSet: true, + separatorMode: "none", + commitMode: "eager", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + session.update("@", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith("@"); + }); + + test("empty input: ambiguous prefix does not re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["@config", "@configure"], 0, { + separatorMode: "none", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + session.update("@config", getPos); + + // "@config" is a prefix of "@configure" — reuse, no re-fetch + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); diff --git a/ts/packages/shell/test/tsconfig.json b/ts/packages/shell/test/tsconfig.json new file mode 100644 index 000000000..d22e8be3f --- /dev/null +++ b/ts/packages/shell/test/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "rootDir": ".", + "outDir": "../dist/test", + "types": ["node", "jest"] + }, + "include": ["partialCompletion/**/*.ts"], + "references": [{ "path": "../src" }] +} diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index 54c39d118..acd10f9fe 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -2948,6 +2948,9 @@ importers: '@types/markdown-it': specifier: ^14.1.2 version: 14.1.2 + rimraf: + specifier: ^6.0.1 + version: 6.0.1 typescript: specifier: ~5.4.5 version: 5.4.5 @@ -4294,12 +4297,18 @@ importers: '@fontsource/lato': specifier: ^5.2.5 version: 5.2.5 + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 '@playwright/test': specifier: ^1.55.0 version: 1.57.0 '@types/debug': specifier: ^4.1.12 version: 4.1.12 + '@types/jest': + specifier: ^29.5.7 + version: 29.5.14 concurrently: specifier: ^9.1.2 version: 9.1.2 @@ -4318,6 +4327,9 @@ importers: electron-vite: specifier: ^4.0.1 version: 4.0.1(vite@6.4.1(@types/node@24.10.13)(jiti@2.5.1)(less@4.3.0)(terser@5.39.2)(yaml@2.8.2)) + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.13)(ts-node@10.9.2(@types/node@24.10.13)(typescript@5.4.5)) less: specifier: ^4.2.0 version: 4.3.0