From d4b59c98350b58cd411b000e59c15322a37a4650 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 17 Mar 2026 12:58:43 -0700 Subject: [PATCH 1/7] Add CompletionDirection (forward/backward) to completion pipeline Thread a CompletionDirection type through the entire completion pipeline so completions can look ahead (forward) or reconsider the previous part (backward) depending on user intent. SDK & RPC: - Add CompletionDirection = 'forward' | 'backward' to agentSdk - Thread direction through agentRpc client/server and dispatcher RPC types Grammar matcher: - Support backward completion for literal parts (not just wildcards) - Add lastStringPartInfo to MatchState for tracking the last matched literal position - Fix lastWildcard bug: clear stale wildcard info when new wildcards are encountered so backward returns the correct startIndex - Pass direction into tryPartialStringMatch for all categories Construction cache: - Thread direction through completion() and related cache methods - Use matchedStarts for backward to locate the previous part position Dispatcher: - Update completion command and request handlers to accept and forward direction Shell: - Auto-detect direction in PartialCompletion.update(): backward only when the new input is a strict prefix of the previous input (genuine backspace), forward otherwise - Remove explicit direction parameter from update(); reset previousInput before post-selection update instead - Pass direction from PartialCompletionSession to dispatcher Tests: - Add backward completion tests for grammar matcher (7 sub-describes) - Add backward completion tests for construction cache - Add 8 backward tests for dispatcher completion - Rename commitMode.spec.ts to direction.spec.ts; update shell tests for direction parameter threading - Update all shell test files for parameter reordering --- ts/docs/architecture/completion.md | 60 ++-- .../actionGrammar/src/grammarMatcher.ts | 227 +++++++++++++-- .../grammarCompletionPrefixLength.spec.ts | 250 ++++++++++++++++ ts/packages/agentRpc/src/client.ts | 3 + ts/packages/agentRpc/src/server.ts | 1 + ts/packages/agentRpc/src/types.ts | 2 + ts/packages/agentSdk/src/command.ts | 22 +- .../agentSdk/src/helpers/commandHelpers.ts | 5 +- ts/packages/agentSdk/src/index.ts | 2 +- ts/packages/cache/src/cache/cache.ts | 11 +- .../cache/src/cache/constructionStore.ts | 14 +- ts/packages/cache/src/cache/grammarStore.ts | 4 +- ts/packages/cache/src/cache/types.ts | 1 + .../src/constructions/constructionCache.ts | 144 ++++++---- .../src/constructions/constructionMatch.ts | 4 + .../src/constructions/constructionValue.ts | 1 + .../cache/src/constructions/constructions.ts | 1 + ts/packages/cache/test/completion.spec.ts | 266 ++++++++++++++++++ ts/packages/cli/src/commands/interactive.ts | 4 +- .../dispatcher/src/command/completion.ts | 167 +++++------ .../handlers/matchCommandHandler.ts | 4 +- .../handlers/requestCommandHandler.ts | 4 +- .../handlers/translateCommandHandler.ts | 4 +- .../dispatcher/dispatcher/src/dispatcher.ts | 4 +- .../src/translation/requestCompletion.ts | 15 +- .../dispatcher/test/completion.spec.ts | 236 ++++++++++++++-- .../dispatcher/rpc/src/dispatcherTypes.ts | 6 +- .../dispatcher/types/src/dispatcher.ts | 14 +- ts/packages/shell/src/renderer/src/partial.ts | 24 +- .../renderer/src/partialCompletionSession.ts | 64 ++--- .../{commitMode.spec.ts => direction.spec.ts} | 130 +++++---- .../partialCompletion/errorHandling.spec.ts | 1 + .../test/partialCompletion/publicAPI.spec.ts | 8 +- .../partialCompletion/separatorMode.spec.ts | 2 + .../startIndexSeparatorContract.spec.ts | 4 - .../stateTransitions.spec.ts | 33 ++- 36 files changed, 1377 insertions(+), 365 deletions(-) rename ts/packages/shell/test/partialCompletion/{commitMode.spec.ts => direction.spec.ts} (73%) diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index 715b26f161..5d49d3d74e 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -14,8 +14,9 @@ that eliminates client-side heuristics. beneath it) decides where completions start (`startIndex`), what separates them from the prefix (`separatorMode`), whether the list is exhaustive (`closedSet`), and when to advance to the next hierarchical level - (`commitMode`). Clients never split input on spaces or guess token - boundaries. + The host provides a `direction` signal ("forward" or "backward") to + resolve structural ambiguity when the input is valid. + Clients never split input on spaces or guess token boundaries. 2. **Longest-match wins** — At every layer (grammar, construction cache, grammar store merge), only completions anchored at the longest matched @@ -43,7 +44,7 @@ User keystroke │ State machine: IDLE → PENDING → ACTIVE │ │ Decides: reuse local trie OR re-fetch from backend │ └────────────────────────┬─────────────────────────────────────┘ - │ dispatcher.getCommandCompletion(input) + │ dispatcher.getCommandCompletion(input, direction) ▼ ┌──────────────────────────────────────────────────────────────┐ │ Dispatcher getCommandCompletion() │ @@ -75,7 +76,6 @@ The return path carries `CommandCompletionResult`: completions: CompletionGroup[]; separatorMode?: SeparatorMode; // "space" | "spacePunctuation" | "optional" | "none" closedSet: boolean; // true → list is exhaustive - commitMode?: CommitMode; // "explicit" | "eager" } ``` @@ -158,7 +158,6 @@ type CompletionGroups = { matchedPrefixLength?: number; // grammar override for startIndex separatorMode?: SeparatorMode; closedSet?: boolean; - commitMode?: CommitMode; }; ``` @@ -181,7 +180,15 @@ strongest requirement. ### 4. Dispatcher **Package:** `packages/dispatcher` -**Entry point:** `getCommandCompletion(input, context)` → `CommandCompletionResult` +**Entry point:** `getCommandCompletion(input, direction, context)` → `CommandCompletionResult` + +The `direction` parameter is a `CompletionDirection` (`"forward" | "backward"`) provided +by the host. It resolves structural ambiguity when the full input is valid — +for example, when a typed command name matches both a complete subcommand and +a prefix of a longer one, direction tells the backend whether to proceed +forward (show what follows) or reconsider backward (show alternatives). +For free-form parameter values, the backend derives mid-token status from +the input's trailing whitespace instead. Orchestrates command resolution, parameter parsing, agent invocation, and built-in completions. @@ -206,12 +213,12 @@ input **`resolveCompletionTarget`** — pure decision function: -| Spec case | Condition | Behavior | -| --------- | -------------------------------------------- | ---------------------------------------------------- | -| 1 | `remainderLength > 0` (partial parse) | Offer what follows longest valid prefix | -| 3a-i | Full parse, no trailing space, string param | Editing free-form value → invoke agent, prefix-match | -| 3a-ii | Full parse, no trailing space, flag name | Uncommitted flag → offer flag alternatives | -| 3b | Full parse, trailing space (or fully quoted) | Offer completions for next parameter/flag | +| Spec case | Condition | Behavior | +| --------- | ------------------------------------------------- | ---------------------------------------------------- | +| 1 | `remainderLength > 0` (partial parse) | Offer what follows longest valid prefix | +| 3a-i | Full parse, no trailing whitespace, string param | Editing free-form value → invoke agent, prefix-match | +| 3a-ii | Full parse, direction="backward", flag name | Reconsidering flag → offer flag alternatives | +| 3b | Full parse, direction="forward" (or fully quoted) | Offer completions for next parameter/flag | **`computeClosedSet`** heuristic: @@ -257,7 +264,7 @@ lifecycle of a completion interaction. | A1 | No active session | Invalidation | Re-fetch | | A2 | Input no longer extends anchor | Invalidation | Re-fetch | | A3 | Non-separator char typed when separator required | Invalidation | Re-fetch | -| B4 | Unique match + eager commit mode | Navigation | Re-fetch next level | +| B4 | Unique match (always fires) | Navigation | Re-fetch next level | | B5 | Separator typed after exact match | Navigation | Re-fetch next level | | C6 | No trie matches + open set | Discovery | Re-fetch | | — | Trie has matches | — | Reuse locally | @@ -320,15 +327,25 @@ text. | `"optional"` | Separator accepted but not required | CJK / mixed-script grammars | | `"none"` | No separator | `[spacing=none]` grammars | -### `CommitMode` +### `CompletionDirection` + +The host-provided signal that resolves structural ambiguity when the input +is fully valid. Instead of the backend telling the client when to advance, +the client tells the backend which direction to complete. + +| Value | Meaning | When the host sends it | +| ------------ | --------------------- | ----------------------------------------------------- | +| `"forward"` | User is moving ahead | Appending characters, typed separator, menu selection | +| `"backward"` | User is reconsidering | Backspacing, deleting | -Controls when a uniquely-satisfied completion triggers a re-fetch for the next -hierarchical level. +Direction is only consulted at structural ambiguity points (command-level +and flag-level resolution). For free-form parameter values the backend +uses the input's trailing whitespace to decide whether the last token is +complete. -| Value | Meaning | Use case | -| ------------ | ------------------------------------ | ------------------------------- | -| `"explicit"` | User must type delimiter to commit | Parameter completions (default) | -| `"eager"` | Re-fetch immediately on unique match | `@` command prefix | +Trigger B4 (unique match) always fires a re-fetch regardless of direction, +since the session can determine locally that the completion is uniquely +satisfied. ### `closedSet` @@ -348,7 +365,8 @@ Merge rule: AND across sources (closed only if _all_ sources are closed). The CLI (`packages/cli/src/commands/interactive.ts`) follows the same contract but with simpler plumbing: -1. Sends full input to `dispatcher.getCommandCompletion(line)` (no +1. Sends full input and a `direction` (always `"forward"` for tab-completion) + to `dispatcher.getCommandCompletion(line, direction)` (no token-boundary heuristics). 2. Uses `result.startIndex` as the readline filter position. 3. Prepends a space separator when `separatorMode` is `"space"` or diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 6d0fae2073..794dae3792 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -237,6 +237,13 @@ type MatchState = { readonly valueId: number | undefined; } | undefined; + + // Completion support: start index and part reference for the last + // matched string part. Used by backward completion to back up to + // the last literal word. + lastStringPartInfo?: + | { readonly start: number; readonly part: StringPart } + | undefined; }; type GrammarMatchStat = { @@ -731,6 +738,7 @@ function matchStringPartWithWildcard( } if (captureWildcard(state, request, wildcardEnd, newIndex, pending)) { + state.lastStringPartInfo = { start: wildcardEnd, part }; debugMatch( state, `Matched string '${part.value.join(" ")}' at ${wildcardEnd}`, @@ -776,6 +784,7 @@ function matchStringPartWithoutWildcard( // default string part value addValue(state, undefined, part.value.join(" ")); } + state.lastStringPartInfo = { start: curr, part }; state.index = newIndex; return true; } @@ -943,8 +952,9 @@ function matchVarStringPart(state: MatchState, part: VarStringPart) { return false; } + const valueId = addValueId(state, part.variable, part.typeName); state.pendingWildcard = { - valueId: addValueId(state, part.variable, part.typeName), + valueId, start: state.index, }; return true; @@ -1189,10 +1199,12 @@ function tryPartialStringMatch( prefix: string, startIndex: number, spacingMode: CompiledSpacingMode, + direction?: "forward" | "backward", ): { consumedLength: number; remainingText: string } | undefined { const words = part.value; let index = startIndex; let matchedWords = 0; + let prevIndex = startIndex; for (const word of words) { const escaped = escapeMatch(word); @@ -1210,11 +1222,25 @@ function tryPartialStringMatch( if (!isBoundarySatisfied(prefix, newIndex, spacingMode)) { break; } + prevIndex = index; index = newIndex; matchedWords++; } - // No partial match found — either zero or all words matched + if (direction === "backward") { + // Back up to the last matched word. If no words matched + // there is nothing to back up to. + if (matchedWords === 0) { + return undefined; + } + return { + consumedLength: prevIndex, + remainingText: words[matchedWords - 1], + }; + } + + // Forward (default): offer the next unmatched word. + // Return undefined when all words matched (exact match). if (matchedWords >= words.length) { return undefined; } @@ -1283,6 +1309,7 @@ export function matchGrammarCompletion( grammar: Grammar, prefix: string, minPrefixLength?: number, + direction?: "forward" | "backward", ): GrammarCompletionResult { debugCompletion(`Start completion for prefix: "${prefix}"`); @@ -1339,6 +1366,10 @@ export function matchGrammarCompletion( // extensions, repeat iterations). const matched = matchState(state, prefix, pending); + // Save the pending wildcard before finalizeState clears it. + // Needed for backward completion of wildcards at the end of a rule. + const savedPendingWildcard = state.pendingWildcard; + // finalizeState does two things: // 1. If a wildcard is pending at the end, attempt to capture // all remaining input as its value. @@ -1349,12 +1380,108 @@ export function matchGrammarCompletion( 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."); - updateMaxPrefixLength(state.index); + if (direction === "backward") { + // The user is backing up — offer alternatives for + // the last wildcard instead of advancing. Use + // savedPendingWildcard only — it is set when the + // wildcard is at the very end (resolved by + // finalizeState). When the wildcard was resolved + // mid-match (a subsequent literal matched), the + // last literal is the more recent part and + // lastStringPartInfo handles it below. + const wildcard = savedPendingWildcard; + if ( + wildcard !== undefined && + wildcard.valueId !== undefined + ) { + debugCompletion( + "Backward exact match: offering wildcard alternatives.", + ); + const completionProperty = getGrammarCompletionProperty( + state, + wildcard.valueId, + ); + if (completionProperty !== undefined) { + updateMaxPrefixLength(wildcard.start); + if (wildcard.start === maxPrefixLength) { + properties.push(completionProperty); + closedSet = false; + // Determine separator mode for the + // property/entity slot (same logic + // as Category 3a). + let candidateNeedsSep = false; + if ( + wildcard.start > 0 && + state.spacingMode !== "none" + ) { + candidateNeedsSep = requiresSeparator( + prefix[wildcard.start - 1], + "a", + state.spacingMode, + ); + } + separatorMode = mergeSeparatorMode( + separatorMode, + candidateNeedsSep, + state.spacingMode, + ); + } + } + } else if (state.lastStringPartInfo !== undefined) { + // No wildcard — back up to the last matched + // literal word so the user can reconsider it. + const { start, part: lastPart } = + state.lastStringPartInfo; + const backResult = tryPartialStringMatch( + lastPart, + prefix, + start, + state.spacingMode, + "backward", + ); + if (backResult !== undefined) { + debugCompletion( + `Backward exact match: offering last literal word "${backResult.remainingText}".`, + ); + updateMaxPrefixLength(backResult.consumedLength); + if (backResult.consumedLength === maxPrefixLength) { + completions.push(backResult.remainingText); + let candidateNeedsSep = false; + if ( + backResult.consumedLength > 0 && + backResult.remainingText.length > 0 && + state.spacingMode !== "none" + ) { + candidateNeedsSep = requiresSeparator( + prefix[backResult.consumedLength - 1], + backResult.remainingText[0], + state.spacingMode, + ); + } + separatorMode = mergeSeparatorMode( + separatorMode, + candidateNeedsSep, + state.spacingMode, + ); + } + } else { + updateMaxPrefixLength(state.index); + } + } else { + // No wildcard and no string parts — shouldn't + // normally happen but advance maxPrefixLength + // so shorter candidates are discarded. + updateMaxPrefixLength(state.index); + } + } else { + // Forward (default): nothing to complete after a + // full match. Record how far we got so that + // completion candidates from shorter partial + // matches are eagerly discarded. + debugCompletion("Matched. Nothing to complete."); + updateMaxPrefixLength(state.index); + } continue; } @@ -1364,30 +1491,28 @@ export function matchGrammarCompletion( // 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") { - // Use tryPartialStringMatch for one-word-at-a-time - // progression through string parts. - const partial = tryPartialStringMatch( - nextPart, + if ( + direction === "backward" && + state.lastStringPartInfo !== undefined + ) { + // Backward: back up to the last matched literal word + // instead of offering the next unmatched part. + const { start, part: lastPart } = state.lastStringPartInfo; + const backResult = tryPartialStringMatch( + lastPart, prefix, - state.index, + start, state.spacingMode, + "backward", ); - if (partial !== undefined) { - const candidatePrefixLength = partial.consumedLength; - const completionText = partial.remainingText; + if (backResult !== undefined) { + const candidatePrefixLength = backResult.consumedLength; + const completionText = backResult.remainingText; updateMaxPrefixLength(candidatePrefixLength); if (candidatePrefixLength === maxPrefixLength) { debugCompletion( - `Adding completion text: "${completionText}" (consumed ${candidatePrefixLength} chars, spacing=${state.spacingMode ?? "auto"})`, + `Backward Category 2: offering "${completionText}" (consumed ${candidatePrefixLength} chars)`, ); - - // 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 && @@ -1400,7 +1525,6 @@ export function matchGrammarCompletion( state.spacingMode, ); } - completions.push(completionText); separatorMode = mergeSeparatorMode( separatorMode, @@ -1409,6 +1533,55 @@ export function matchGrammarCompletion( ); } } + } else { + debugCompletion( + `Completing ${nextPart.type} part ${state.name}`, + ); + if (nextPart.type === "string") { + // Use tryPartialStringMatch for one-word-at-a-time + // progression through string parts. + const partial = tryPartialStringMatch( + nextPart, + prefix, + state.index, + state.spacingMode, + ); + 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 @@ -1496,11 +1669,15 @@ export function matchGrammarCompletion( // 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). + // + // For backward direction, offer the last matched word + // instead of the next unmatched word. const partial = tryPartialStringMatch( currentPart, prefix, state.index, state.spacingMode, + direction, ); if (partial === undefined) { continue; diff --git a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts index 615d22597d..9b94ce84ce 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts @@ -354,4 +354,254 @@ describe("Grammar Completion - matchedPrefixLength", () => { expect(result.separatorMode).toBe("spacePunctuation"); }); }); + + describe("backward direction", () => { + describe("all-literal single string part", () => { + const g = ` = play music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("exact match backward offers last literal word", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "backward", + ); + // Backward backs up to the last matched word "music" + // and re-offers it as a completion. + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("forward exact match still returns empty completions", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "forward", + ); + expect(result.completions).toHaveLength(0); + expect(result.matchedPrefixLength).toBe(10); + }); + }); + + describe("three-word single string part", () => { + const g = ` = play music now -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("partial match backward offers last matched word", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "backward", + ); + // Backward: "play" and "music" matched, so it backs + // up to offer "music" (the last matched word). + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("partial match forward offers next unmatched word", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "forward", + ); + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(10); + }); + }); + + describe("multi-part via nested rule", () => { + const g = [ + ` = $(v:) music now -> true;`, + ` = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward backs up to last matched literal", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "backward", + ); + // "play" matched the verb rule, "music" matched the + // second word. Backward backs up to "music". + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("forward offers next unmatched word", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "forward", + ); + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(10); + }); + }); + + describe("wildcard at end", () => { + const g = [ + `entity TrackName;`, + ` = play $(name:TrackName) -> { actionName: "play", parameters: { name } };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward on exact match backs up to wildcard start with property", () => { + const result = matchGrammarCompletion( + grammar, + "play hello", + undefined, + "backward", + ); + // Backward: backs up to wildcard start (after "play" = 4) + // and offers entity property completions. + expect(result.properties?.length).toBeGreaterThan(0); + expect(result.matchedPrefixLength).toBe(4); + expect(result.closedSet).toBe(false); + }); + + it("forward on exact match returns empty completions", () => { + const result = matchGrammarCompletion( + grammar, + "play hello", + undefined, + "forward", + ); + expect(result.completions).toHaveLength(0); + expect(result.matchedPrefixLength).toBe(10); + }); + }); + + describe("wildcard in middle", () => { + const g = [ + `entity TrackName;`, + ` = play $(name:TrackName) now -> { actionName: "play", parameters: { name } };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward on exact match backs up to last literal 'now'", () => { + const result = matchGrammarCompletion( + grammar, + "play hello now", + undefined, + "backward", + ); + // Backward: the wildcard was captured mid-match when + // "now" matched, so "now" is the last matched part. + // Backward backs up to offer "now" (not the wildcard). + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(10); + }); + + it("forward offers 'now' (greedy wildcard alternative)", () => { + const result = matchGrammarCompletion( + grammar, + "play hello now", + undefined, + "forward", + ); + // The wildcard greedily consumed "hello now", so the + // "now" string part is still unmatched — it appears + // as a completion at the same prefix length. + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(14); + }); + }); + + describe("wildcard followed by multiple literals", () => { + const g = [ + `entity TrackName;`, + ` = play $(name:TrackName) right now -> { actionName: "play", parameters: { name } };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward backs up to last literal 'now', not to wildcard", () => { + const result = matchGrammarCompletion( + grammar, + "play hello right now", + undefined, + "backward", + ); + // "play" at 0, wildcard "hello" captured at 4-10, + // "right" at 10, "now" at 16. + // Backward should back up to the LAST literal "now". + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(16); + }); + + it("forward on exact match offers greedy wildcard alternative", () => { + const result = matchGrammarCompletion( + grammar, + "play hello right now", + undefined, + "forward", + ); + // Greedy wildcard consumed "hello right now", so + // "right" is still unmatched as an alternative. + expect(result.completions).toEqual(["right"]); + expect(result.matchedPrefixLength).toBe(20); + }); + }); + + describe("backward on partial input backs up to first word", () => { + const g = ` = play music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("backward on 'play ' backs up to 'play'", () => { + const result = matchGrammarCompletion( + grammar, + "play ", + undefined, + "backward", + ); + // Only "play" matched. Backward backs up to offer + // "play" at position 0 (reconsider the first word). + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("forward on 'play ' offers next word", () => { + const result = matchGrammarCompletion( + grammar, + "play ", + undefined, + "forward", + ); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("multi-rule with shared prefix and wildcard", () => { + const g = [ + `entity TrackName;`, + ` = play $(name:TrackName) -> { actionName: "play", parameters: { name } };`, + ` = play music -> "play_music";`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward on 'play music' offers both literal and property at same position", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "backward", + ); + // Both rules back up to position 4: the all-literal + // rule offers "music", the wildcard rule offers a + // property completion. + expect(result.completions).toEqual(["music"]); + expect(result.properties?.length).toBeGreaterThan(0); + expect(result.matchedPrefixLength).toBe(4); + expect(result.closedSet).toBe(false); + }); + }); + }); }); diff --git a/ts/packages/agentRpc/src/client.ts b/ts/packages/agentRpc/src/client.ts index 30a092e4fd..857861b3da 100644 --- a/ts/packages/agentRpc/src/client.ts +++ b/ts/packages/agentRpc/src/client.ts @@ -9,6 +9,7 @@ import { DisplayContent, DisplayAppendMode, CommandDescriptors, + CompletionDirection, ParsedCommandParams, ParameterDefinitions, ClientAction, @@ -513,12 +514,14 @@ export async function createAgentRpcClient( params: ParsedCommandParams, names: string[], context: SessionContext, + direction?: CompletionDirection, ) { return rpc.invoke("getCommandCompletion", { ...getContextParam(context), commands, params, names, + ...(direction !== undefined ? { direction } : {}), }); }, executeCommand( diff --git a/ts/packages/agentRpc/src/server.ts b/ts/packages/agentRpc/src/server.ts index 9b5b0e1555..45ca6fab7f 100644 --- a/ts/packages/agentRpc/src/server.ts +++ b/ts/packages/agentRpc/src/server.ts @@ -179,6 +179,7 @@ export function createAgentRpcServer( param.params, param.names, getSessionContextShim(param), + param.direction, ); }, async executeCommand(param) { diff --git a/ts/packages/agentRpc/src/types.ts b/ts/packages/agentRpc/src/types.ts index 9a73f19cc2..8e1b1a55c0 100644 --- a/ts/packages/agentRpc/src/types.ts +++ b/ts/packages/agentRpc/src/types.ts @@ -20,6 +20,7 @@ import { StorageListOptions, TemplateSchema, TypeAgentAction, + CompletionDirection, CompletionGroups, ResolveEntityResult, } from "@typeagent/agent-sdk"; @@ -181,6 +182,7 @@ export type AgentInvokeFunctions = { commands: string[]; params: ParsedCommandParams; names: string[]; + direction?: CompletionDirection; }, ): Promise; executeCommand( diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 970309219c..3e12b68677 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -66,14 +66,14 @@ export type CommandDescriptors = // 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"; +// Indicates the user's editing direction, provided by the host. +// "current" — the user is still editing the last token (e.g. appending +// characters, or just deleted/backspaced). The backend should +// offer completions that replace/extend the current token. +// "next" — the user has committed the last token (e.g. typed a +// separator, selected a menu item). The backend should offer +// completions for the next position. +export type CompletionDirection = "forward" | "backward"; export type CompletionGroup = { name: string; // The group name for the completion @@ -104,11 +104,6 @@ export type CompletionGroups = { // 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 { @@ -121,6 +116,7 @@ export interface AppAgentCommandInterface { params: ParsedCommandParams | undefined, names: string[], // array of or -- or -- for completion context: SessionContext, + direction?: CompletionDirection, ): Promise; // Execute a resolved command. Exception from the execution are treated as errors and displayed to the user. diff --git a/ts/packages/agentSdk/src/helpers/commandHelpers.ts b/ts/packages/agentSdk/src/helpers/commandHelpers.ts index 0f45a1b48f..6f7b94efc7 100644 --- a/ts/packages/agentSdk/src/helpers/commandHelpers.ts +++ b/ts/packages/agentSdk/src/helpers/commandHelpers.ts @@ -7,6 +7,7 @@ import { CommandDescriptor, CommandDescriptors, CommandDescriptorTable, + CompletionDirection, CompletionGroups, SeparatorMode, } from "../command.js"; @@ -61,6 +62,7 @@ export type CommandHandler = CommandDescriptor & { context: SessionContext, params: PartialParsedCommandParams, names: string[], + direction?: CompletionDirection, ): Promise; }; @@ -188,10 +190,11 @@ export function getCommandInterface( params: ParsedCommandParams, names: string[], context: SessionContext, + direction?: CompletionDirection, ) => { const handler = getCommandHandler(handlers, commands); return ( - handler.getCompletion?.(context, params, names) ?? { + handler.getCompletion?.(context, params, names, direction) ?? { groups: [], } ); diff --git a/ts/packages/agentSdk/src/index.ts b/ts/packages/agentSdk/src/index.ts index bed6aa1793..ecdfbe74f5 100644 --- a/ts/packages/agentSdk/src/index.ts +++ b/ts/packages/agentSdk/src/index.ts @@ -29,7 +29,7 @@ export { CommandDescriptors, CommandDescriptorTable, AppAgentCommandInterface, - CommitMode, + CompletionDirection, CompletionGroup, CompletionGroups, SeparatorMode, diff --git a/ts/packages/cache/src/cache/cache.ts b/ts/packages/cache/src/cache/cache.ts index be93c2a52b..a57b7061b3 100644 --- a/ts/packages/cache/src/cache/cache.ts +++ b/ts/packages/cache/src/cache/cache.ts @@ -29,6 +29,7 @@ import { mergeCompletionResults, NamespaceKeyFilter, } from "../constructions/constructionCache.js"; +import { CompletionDirection } from "@typeagent/agent-sdk"; import { ExplainWorkQueue, ExplanationOptions, @@ -613,20 +614,26 @@ export class AgentCache { public completion( requestPrefix: string, options?: MatchOptions, + direction?: CompletionDirection, ): CompletionResult | undefined { // If NFA grammar system is configured, only use grammar store if (this._useNFAGrammar) { const grammarStore = this._grammarStore; - return grammarStore.completion(requestPrefix, options); + return grammarStore.completion(requestPrefix, options, direction); } // Otherwise use completion-based construction store (with grammar store fallback) const store = this._constructionStore; - const storeCompletion = store.completion(requestPrefix, options); + const storeCompletion = store.completion( + requestPrefix, + options, + direction, + ); const grammarStore = this._grammarStore; const grammarCompletion = grammarStore.completion( requestPrefix, options, + direction, ); return mergeCompletionResults(storeCompletion, grammarCompletion); } diff --git a/ts/packages/cache/src/cache/constructionStore.ts b/ts/packages/cache/src/cache/constructionStore.ts index be13021d95..291cd89d08 100644 --- a/ts/packages/cache/src/cache/constructionStore.ts +++ b/ts/packages/cache/src/cache/constructionStore.ts @@ -20,6 +20,7 @@ import { mergeCompletionResults, NamespaceKeyFilter, } from "../constructions/constructionCache.js"; +import { CompletionDirection } from "@typeagent/agent-sdk"; import { PrintOptions, printConstructionCache, @@ -395,11 +396,20 @@ export class ConstructionStoreImpl implements ConstructionStore { return sortedMatches; } - public completion(requestPrefix: string, options?: MatchOptions) { - const cacheCompletion = this.cache?.completion(requestPrefix, options); + public completion( + requestPrefix: string, + options?: MatchOptions, + direction?: CompletionDirection, + ) { + const cacheCompletion = this.cache?.completion( + requestPrefix, + options, + direction, + ); const builtInCompletion = this.builtInCache?.completion( requestPrefix, options, + direction, ); return mergeCompletionResults(cacheCompletion, builtInCompletion); diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index 758da8cf23..6c48a6fbba 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -18,7 +18,7 @@ import { } from "action-grammar"; const debug = registerDebug("typeagent:cache:grammarStore"); -import { SeparatorMode } from "@typeagent/agent-sdk"; +import { CompletionDirection, SeparatorMode } from "@typeagent/agent-sdk"; import { mergeSeparatorMode } from "@typeagent/agent-sdk/helpers/command"; import { CompletionProperty, @@ -264,6 +264,7 @@ export class GrammarStoreImpl implements GrammarStore { public completion( requestPrefix: string, options?: MatchOptions, + direction?: CompletionDirection, ): CompletionResult | undefined { if (!this.enabled) { return undefined; @@ -353,6 +354,7 @@ export class GrammarStoreImpl implements GrammarStore { entry.grammar, requestPrefix, matchedPrefixLength, + direction, ); const partialPrefixLength = partial.matchedPrefixLength ?? 0; if (partialPrefixLength > matchedPrefixLength) { diff --git a/ts/packages/cache/src/cache/types.ts b/ts/packages/cache/src/cache/types.ts index 739086b0f6..a7e2d13125 100644 --- a/ts/packages/cache/src/cache/types.ts +++ b/ts/packages/cache/src/cache/types.ts @@ -14,6 +14,7 @@ export type MatchResult = { conflictValues?: [string, ParamValueType[]][] | undefined; partialPartCount?: number | undefined; // Only used for partial match partialMatchedCurrent?: number | undefined; // Character offset where partial matching stopped + matchedStarts?: readonly number[] | undefined; // Start position of each matched part (partial only; -1 = optional skipped) }; export interface GrammarStore { diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 846de67523..ecd804bf5f 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SeparatorMode } from "@typeagent/agent-sdk"; +import { CompletionDirection, SeparatorMode } from "@typeagent/agent-sdk"; import { mergeSeparatorMode } from "@typeagent/agent-sdk/helpers/command"; import { ExecutableAction, @@ -10,6 +10,7 @@ import { import { Construction, ConstructionMatchResult, + ConstructionPart, WildcardMode, } from "./constructions.js"; import { MatchPart, MatchSet, isMatchPart } from "./matchPart.js"; @@ -335,6 +336,7 @@ export class ConstructionCache { request: string, options?: MatchOptions, partial?: boolean, + needMatchedStarts?: boolean, ): ConstructionMatchResult[] { const namespaceKeys = options?.namespaceKeys; if (namespaceKeys?.length === 0) { @@ -348,6 +350,7 @@ export class ConstructionCache { conflicts: options?.conflicts, matchPartsCache: createMatchPartsCache(request), partial: partial ?? false, // default to false. + needMatchedStarts: needMatchedStarts ?? false, }; // If the useTranslators is undefined use all the translators @@ -374,12 +377,14 @@ export class ConstructionCache { public completion( requestPrefix: string, options?: MatchOptions, + direction?: CompletionDirection, ): CompletionResult | undefined { debugCompletion(`Request completion for prefix: '${requestPrefix}'`); const namespaceKeys = options?.namespaceKeys; debugCompletion(`Request completion namespace keys`, namespaceKeys); - const results = this.match(requestPrefix, options, true); + const backward = direction === "backward"; + const results = this.match(requestPrefix, options, true, backward); debugCompletion( `Request completion construction match: ${results.length}`, @@ -402,6 +407,8 @@ export class ConstructionCache { // are added (entity values are external). Reset to true when // maxPrefixLength advances (old candidates discarded). let closedSet: boolean = true; + const rejectReferences = options?.rejectReferences ?? true; + const langTools = getLanguageTools("en"); function updateMaxPrefixLength(prefixLength: number): void { if (prefixLength > maxPrefixLength) { @@ -422,38 +429,60 @@ export class ConstructionCache { ); } - if (partialPartCount === construction.parts.length) { - // Exact match — all parts matched. Nothing to complete, - // but advance maxPrefixLength so shorter candidates are - // discarded. - updateMaxPrefixLength(requestPrefix.length); - continue; + // --- Step 1: Determine which part to complete and the + // prefix length up to that point. --- + let completionPart: ConstructionPart | undefined; + let candidatePrefixLength: number; + + if (backward) { + // Walk matchedStarts backwards to find the last part + // that actually matched (skip optional parts = -1). + const matchedStarts = result.matchedStarts; + candidatePrefixLength = -1; + if (matchedStarts !== undefined && partialPartCount > 0) { + for (let i = partialPartCount - 1; i >= 0; i--) { + if (matchedStarts[i] >= 0) { + completionPart = construction.parts[i]; + candidatePrefixLength = matchedStarts[i]; + break; + } + } + } + if (candidatePrefixLength < 0) { + continue; // Nothing matched to back up to + } + } else { + // Forward: exact match means nothing to complete. + if (partialPartCount === construction.parts.length) { + updateMaxPrefixLength(requestPrefix.length); + continue; + } + completionPart = construction.parts[partialPartCount]; + candidatePrefixLength = partialMatchedCurrent ?? 0; } - const candidatePrefixLength = partialMatchedCurrent ?? 0; + // --- Step 2: Check against maxPrefixLength --- updateMaxPrefixLength(candidatePrefixLength); if (candidatePrefixLength !== maxPrefixLength) { continue; // Shorter than the best match — skip } - const nextPart = construction.parts[partialPartCount]; - // Only include part completion if it is not a checked or entity wildcard. - if (nextPart.wildcardMode <= WildcardMode.Enabled) { - const partCompletions = nextPart.getCompletion(); + // --- Step 3: Offer literal completions from the part --- + if ( + completionPart !== undefined && + completionPart.wildcardMode <= WildcardMode.Enabled + ) { + const partCompletions = completionPart.getCompletion(); if (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 && + completionPart.capture && rejectReferences && langTools?.possibleReferentialPhrase(completionText) ) { continue; } requestText.push(completionText); - // Determine separator mode for this candidate. if ( candidatePrefixLength > 0 && completionText.length > 0 @@ -471,51 +500,48 @@ export class ConstructionCache { } } - // TODO: assuming the partial action doesn't change the possible values. - const nextPartPropertyNames = nextPart.getPropertyNames(); - if ( - nextPartPropertyNames !== undefined && - nextPartPropertyNames.length > 0 - ) { - // Detect multi-part properties - const allPropertyNames = new Map(); - for (const part of construction.parts) { - const names = part.getPropertyNames(); - if (names === undefined) { - continue; // No property names for this part - } - for (const name of names) { - const count = allPropertyNames.get(name) ?? 0; - allPropertyNames.set(name, count + 1); + // --- Step 4: Offer property completions for entity parts --- + if (completionPart !== undefined) { + const partPropertyNames = completionPart.getPropertyNames(); + if ( + partPropertyNames !== undefined && + partPropertyNames.length > 0 + ) { + // Filter out properties that appear in multiple parts + // so we only offer single-part properties. + const allPropertyNames = new Map(); + for (const part of construction.parts) { + const names = part.getPropertyNames(); + if (names === undefined) { + continue; + } + for (const name of names) { + const count = allPropertyNames.get(name) ?? 0; + allPropertyNames.set(name, count + 1); + } } - } - const queryPropertyNames = nextPartPropertyNames.filter( - (name) => allPropertyNames.get(name) === 1, - ); - if (queryPropertyNames.length === 0) { - continue; // No single-part properties to complete - } - completionProperty.push({ - 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", + const queryPropertyNames = partPropertyNames.filter( + (name: string) => allPropertyNames.get(name) === 1, ); + if (queryPropertyNames.length > 0) { + completionProperty.push({ + actions: result.match.actions, + names: queryPropertyNames, + }); + if (candidatePrefixLength > 0) { + const needsSep = needsSeparatorInAutoMode( + requestPrefix[candidatePrefixLength - 1], + "a", + ); + separatorMode = mergeSeparatorMode( + separatorMode, + needsSep ? "spacePunctuation" : "optional", + ); + } + closedSet = false; + } } - // Property/wildcard completions are not a closed set — - // entity values are external. - closedSet = false; } } diff --git a/ts/packages/cache/src/constructions/constructionMatch.ts b/ts/packages/cache/src/constructions/constructionMatch.ts index 191a24923e..07a3a61a2a 100644 --- a/ts/packages/cache/src/constructions/constructionMatch.ts +++ b/ts/packages/cache/src/constructions/constructionMatch.ts @@ -36,6 +36,7 @@ export type MatchConfig = { readonly history?: HistoryContext | undefined; readonly matchPartsCache?: MatchPartsCache | undefined; readonly conflicts?: boolean | undefined; + readonly needMatchedStarts?: boolean | undefined; }; export function matchParts( @@ -65,6 +66,9 @@ export function matchParts( if (config.partial) { values.partialPartCount = state.matchedStart.length; values.matchedCurrent = state.matchedCurrent; + if (config.needMatchedStarts) { + values.matchedStarts = [...state.matchedStart]; + } } return values; } diff --git a/ts/packages/cache/src/constructions/constructionValue.ts b/ts/packages/cache/src/constructions/constructionValue.ts index 0a79147860..44d077f53d 100644 --- a/ts/packages/cache/src/constructions/constructionValue.ts +++ b/ts/packages/cache/src/constructions/constructionValue.ts @@ -38,6 +38,7 @@ export type MatchedValues = { wildcardCharCount: number; partialPartCount?: number; // Only used for partial match matchedCurrent?: number; // Character offset where matching stopped (partial only) + matchedStarts?: readonly number[]; // Start position of each matched part (partial only; -1 = optional skipped) }; export function matchedValues( diff --git a/ts/packages/cache/src/constructions/constructions.ts b/ts/packages/cache/src/constructions/constructions.ts index f5ea1e159a..267151bb32 100644 --- a/ts/packages/cache/src/constructions/constructions.ts +++ b/ts/packages/cache/src/constructions/constructions.ts @@ -161,6 +161,7 @@ export class Construction { implicitParameterCount: this.implicitParameterCount, partialPartCount: matchedValues.partialPartCount, partialMatchedCurrent: matchedValues.matchedCurrent, + matchedStarts: matchedValues.matchedStarts, }, ]; } diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index da7866045d..bcf33bec4f 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -778,4 +778,270 @@ describe("ConstructionCache.completion()", () => { }); }); }); + + describe("backward direction", () => { + describe("literal-only construction", () => { + it("backs up to last part start on exact match", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + // Backs up to last part start ("play" consumed 4 + // chars; the space is a separator, not part of any + // match part). + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(4); + }); + + it("backs up to last part start for single-part construction", () => { + const c = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + // Single part — backs up to 0 (the start of the only part). + expect(result!.completions).toContain("play"); + expect(result!.matchedPrefixLength).toBe(0); + }); + + it("forward exact match still returns empty completions", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song", + defaultOptions, + "forward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toEqual([]); + expect(result!.matchedPrefixLength).toBe(9); + }); + }); + + describe("multi-alternative last part", () => { + it("offers all alternatives from last part on backward", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song", "track", "album"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + expect(result!.completions.sort()).toEqual([ + "album", + "song", + "track", + ]); + expect(result!.matchedPrefixLength).toBe(4); + }); + }); + + describe("entity wildcard at end", () => { + it("backward on exact match offers property completions", () => { + 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, + "backward", + ); + expect(result).toBeDefined(); + // Backward backs up to last part start and offers + // property completions for the entity wildcard. + expect(result!.properties).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("songName"); + expect(result!.closedSet).toBe(false); + }); + + it("forward on exact match with wildcard returns empty", () => { + 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, + "forward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toEqual([]); + expect(result!.matchedPrefixLength).toBe(12); + }); + }); + + describe("wildcard in middle", () => { + it("backward on full match backs up to last wildcard part", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createEntityPart("track", "trackName"), + createMatchPart(["by"], "prep"), + createEntityPart("artist", "artist"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play some song by john", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + // Last part is the artist entity — backward offers + // property completions for it. + expect(result!.properties).toBeDefined(); + expect(result!.properties!.length).toBeGreaterThan(0); + expect(result!.properties![0].names).toContain("artist"); + expect(result!.closedSet).toBe(false); + }); + }); + + describe("partial match backs up to previous part", () => { + it("backward on 'play ' backs up to 'play'", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play ", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + // "play" was the last matched part. Backward backs + // up to offer "play" at position 0. + expect(result!.completions).toContain("play"); + expect(result!.matchedPrefixLength).toBe(0); + }); + + it("forward on 'play ' offers next part", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play ", + defaultOptions, + "forward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(4); + }); + }); + + describe("partial match with three parts", () => { + it("backward on 'play song' (2 of 3 parts matched) backs up to 'song'", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + createMatchPart(["now"], "adv"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(4); + }); + + it("forward on 'play song' (2 of 3 parts matched) offers 'now'", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + createMatchPart(["now"], "adv"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song", + defaultOptions, + "forward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toContain("now"); + expect(result!.matchedPrefixLength).toBe(9); + }); + }); + + describe("trailing optional skipped", () => { + it("backward skips trailing optional and backs up to last real part", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + createMatchPart(["now"], "adv", { optional: true }), + ], + new Map(), + ); + const cache = makeCache([c]); + // "play song" matches parts 0 and 1; part 2 is + // optional and skipped (matchedStarts[2] = -1). + // Backward should skip the optional and back up to + // "song" (the last real match), not to -1. + const result = cache.completion( + "play song", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(4); + }); + }); + }); }); diff --git a/ts/packages/cli/src/commands/interactive.ts b/ts/packages/cli/src/commands/interactive.ts index bf31de348e..e4b0276209 100644 --- a/ts/packages/cli/src/commands/interactive.ts +++ b/ts/packages/cli/src/commands/interactive.ts @@ -67,7 +67,9 @@ async function getCompletionsData( // 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); + // CLI tab-completion is always a forward action. + const direction = "forward" as const; + const result = await dispatcher.getCommandCompletion(line, direction); if (result.completions.length === 0) { return null; } diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index c773345fe2..e9fc9e056b 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -4,8 +4,8 @@ import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import { - CommitMode, CommandDescriptor, + CompletionDirection, FlagDefinitions, ParameterDefinitions, CompletionGroup, @@ -77,6 +77,19 @@ function detectPendingFlag( // 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. +// +// Every production site for startIndex — resolveCommand consumed +// length, parseParams remainder, and the lastCompletableParam +// adjustment — calls this function so that startIndex always lands +// on a token boundary, never on separator whitespace. Consumers +// treat input[startIndex..] as a "rawPrefix" that starts with a +// separator (per separatorMode, defaulting to "space") and strip +// the leading separator before trie filtering. +// +// The grammar-reported matchedPrefixLength override is added to +// tokenStartIndex (before the separator space), not to the result +// of this function — the grammar reports how many characters of the +// token *content* it consumed, which is relative to the token start. function tokenBoundary(text: string, index: number): number { while (index > 0 && /\s/.test(text[index - 1])) { index--; @@ -108,16 +121,16 @@ 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 +// - input has no trailing whitespace and no pending flag function isEditingFreeFormValue( quoted: boolean | undefined, implicitQuotes: boolean, - hasTrailingSpace: boolean, + inputEndsMidToken: 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); + return implicitQuotes || (inputEndsMidToken && pendingFlag === undefined); } // Determine closedSet for parameter completion: @@ -163,11 +176,7 @@ function collectFlags( } // 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; -}; +type ParameterCompletionResult = CommandCompletionResult; // ── resolveCompletionTarget ────────────────────────────────────────────── // @@ -207,7 +216,7 @@ function resolveCompletionTarget( params: ParseParamsResult, flags: FlagDefinitions | undefined, input: string, - hasTrailingSpace: boolean, + direction: CompletionDirection, ): CompletionTarget { const remainderIndex = input.length - params.remainderLength; @@ -244,7 +253,7 @@ function resolveCompletionTarget( isEditingFreeFormValue( quoted, lastParamImplicitQuotes, - hasTrailingSpace, + !/\s$/.test(input), pendingFlag, ) ) { @@ -261,12 +270,13 @@ function resolveCompletionTarget( } } - // 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) { + // 3a-ii: reconsidering flag name. A recognized flag was consumed + // but the user is backing up (direction="backward") — they + // want to reconsider their choice (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 && direction === "backward") { const flagToken = tokens[tokens.length - 1]; const flagTokenStart = remainderIndex - flagToken.length; const startIndex = tokenBoundary(input, flagTokenStart); @@ -282,8 +292,8 @@ function resolveCompletionTarget( // ── 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. + if (pendingFlag !== undefined && direction === "forward") { + // Flag awaiting a value and the user moved forward. return { completionNames: [pendingFlag], startIndex, @@ -323,19 +333,19 @@ function resolveCompletionTarget( // // 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. +// implicitQuotes, or input has no trailing whitespace +// with no pending flag. 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. +// ii. Reconsidering flag name (pendingFlag with direction= +// "backward"): the flag was recognized but the user backed +// up to reconsider. 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. +// b. Otherwise — the last token is complete (direction="forward", +// fully quoted, or trailing whitespace). Return startIndex +// at the *end* of the last token (excluding trailing space) +// and offer completions for the next parameters. // // ── Exceptions to case 3a ──────────────────────────────────────────────── // @@ -343,11 +353,11 @@ function resolveCompletionTarget( // (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: +// even when direction="forward": // -// • 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") +// • A number arg being edited (e.g. "cmd 42") +// • A boolean arg being edited (e.g. "cmd true") +// • A number flag value being edited (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 @@ -363,7 +373,7 @@ async function getCommandParameterCompletion( context: CommandHandlerContext, result: ResolveCommandResult, input: string, - hasTrailingSpace: boolean, + direction: CompletionDirection, ): Promise { if (typeof descriptor.parameters !== "object") { return undefined; @@ -379,7 +389,7 @@ async function getCommandParameterCompletion( params, descriptor.parameters.flags, input, - hasTrailingSpace, + direction, ); let { startIndex } = target; debug( @@ -415,7 +425,6 @@ async function getCommandParameterCompletion( // 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); @@ -430,6 +439,7 @@ async function getCommandParameterCompletion( params, target.completionNames, sessionContext, + direction, ); // Allow grammar-reported matchedPrefixLength to override @@ -457,7 +467,6 @@ async function getCommandParameterCompletion( separatorMode = agentResult.separatorMode; agentInvoked = true; agentClosedSet = agentResult.closedSet; - agentCommitMode = agentResult.commitMode; debug( `Command completion parameter with agent: groupPrefixLength=${groupPrefixLength}, startIndex=${startIndex}, tokenStartIndex=${target.tokenStartIndex}`, ); @@ -473,7 +482,6 @@ async function getCommandParameterCompletion( target.isPartialValue, params.nextArgs.length > 0, ), - commitMode: agentCommitMode, }; } @@ -484,14 +492,13 @@ async function completeDescriptor( context: CommandHandlerContext, result: ResolveCommandResult, input: string, - hasTrailingSpace: boolean, + direction: CompletionDirection, commandConsumedLength: number, ): Promise<{ completions: CompletionGroup[]; startIndex: number | undefined; separatorMode: SeparatorMode | undefined; closedSet: boolean; - commitMode: CommitMode | undefined; }> { const completions: CompletionGroup[] = []; let separatorMode: SeparatorMode | undefined; @@ -501,7 +508,7 @@ async function completeDescriptor( context, result, input, - hasTrailingSpace, + direction, ); // Include sibling subcommand names when resolved to the default @@ -532,7 +539,6 @@ async function completeDescriptor( startIndex: undefined, separatorMode, closedSet: true, - commitMode: undefined, }; } @@ -545,15 +551,30 @@ async function completeDescriptor( 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. +// Given a partial user input string and a direction hint from the host, +// returns the longest valid prefix, available completions from that point, +// and metadata about how they attach. +// +// The `direction` parameter resolves structural ambiguity when the +// input is fully valid: +// "forward" — the user is moving forward (appending characters, typed +// a separator, selected a menu item). Proceed to what +// follows the current position. +// "backward" — the user is backing up (backspaced/deleted). Reconsider +// the current position, e.g. offer alternative commands +// or flag names. +// +// Direction is only consulted at structural ambiguity points — where +// the input is valid but could mean either "stay at this level" or +// "advance to the next level". For free-form parameter values, +// the input's trailing whitespace is used instead (no ambiguity to +// resolve; trailing space means the token is complete). // // Always returns a result — every input has a longest valid prefix // (at minimum the empty string, startIndex=0). An empty completions @@ -580,23 +601,6 @@ async function completeDescriptor( // 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, @@ -613,14 +617,11 @@ async function completeDescriptor( // 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. +// no other valid input exists. // export async function getCommandCompletion( input: string, + direction: CompletionDirection, context: CommandHandlerContext, ): Promise { try { @@ -642,24 +643,32 @@ export async function getCommandCompletion( `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[] = []; - let commitMode: "explicit" | "eager" = "explicit"; let separatorMode: SeparatorMode | undefined; let closedSet = true; const descriptor = result.descriptor; // 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. + // user is backing up (direction="backward"), they want to + // reconsider the command choice. Offer subcommand alternatives + // at that token's position instead of proceeding to parameter + // completions. + // + // However, when normalizeCommand inserts implicit tokens + // (e.g. prepending the default agent and default subcommand + // for empty input), those tokens are inherently committed — + // the user never typed them. Detect this by checking whether + // the normalized command ends with whitespace, which indicates + // the resolver already considers the last token committed. + const normalizedCommitted = /\s$/.test(partialCommand); const uncommittedCommand = descriptor !== undefined && result.matched && - !hasTrailingSpace && + direction === "backward" && + !normalizedCommitted && result.suffix === "" && table !== undefined; @@ -679,7 +688,7 @@ export async function getCommandCompletion( context, result, input, - hasTrailingSpace, + direction, commandConsumedLength, ); completions.push(...desc.completions); @@ -691,9 +700,6 @@ export async function getCommandCompletion( 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 @@ -750,17 +756,14 @@ export async function getCommandCompletion( completions: ["@"], }); - // The first token doesn't require separator before it (separatorMode to optional) - // and it doesn't require space after it (commitMode to eager) + // The first token doesn't require separator before it separatorMode = "optional"; - commitMode = "eager"; } const completionResult: CommandCompletionResult = { startIndex, completions, separatorMode, closedSet, - commitMode, }; debug(`Command completion result:`, completionResult); 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 238458429b..a31cce7caf 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts @@ -4,6 +4,7 @@ import { CommandHandlerContext } from "../../commandHandlerContext.js"; import { ActionContext, + CompletionDirection, CompletionGroups, ParsedCommandParams, SessionContext, @@ -51,6 +52,7 @@ export class MatchCommandHandler implements CommandHandler { context: SessionContext, params: ParsedCommandParams, names: string[], + direction?: CompletionDirection, ): Promise { const result: CompletionGroups = { groups: [] }; for (const name of names) { @@ -59,12 +61,12 @@ export class MatchCommandHandler implements CommandHandler { const requestResult = await requestCompletion( requestPrefix, context.agentContext, + direction, ); result.groups.push(...requestResult.groups); result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; - result.commitMode = requestResult.commitMode; } } 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 cfe2a3adb5..083278dbaa 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts @@ -35,6 +35,7 @@ import { ActionContext, ParsedCommandParams, SessionContext, + CompletionDirection, CompletionGroups, } from "@typeagent/agent-sdk"; import { CommandHandler } from "@typeagent/agent-sdk/helpers/command"; @@ -474,6 +475,7 @@ export class RequestCommandHandler implements CommandHandler { context: SessionContext, params: ParsedCommandParams, names: string[], + direction?: CompletionDirection, ): Promise { const result: CompletionGroups = { groups: [] }; for (const name of names) { @@ -482,12 +484,12 @@ export class RequestCommandHandler implements CommandHandler { const requestResult = await requestCompletion( requestPrefix, context.agentContext, + direction, ); result.groups.push(...requestResult.groups); result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; - result.commitMode = requestResult.commitMode; } } 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 807b716122..85006f33e1 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts @@ -5,6 +5,7 @@ import { CommandHandlerContext } from "../../commandHandlerContext.js"; import { openai as ai } from "aiclient"; import { ActionContext, + CompletionDirection, CompletionGroups, ParsedCommandParams, SessionContext, @@ -76,6 +77,7 @@ export class TranslateCommandHandler implements CommandHandler { context: SessionContext, params: ParsedCommandParams, names: string[], + direction?: CompletionDirection, ): Promise { const result: CompletionGroups = { groups: [] }; for (const name of names) { @@ -84,12 +86,12 @@ export class TranslateCommandHandler implements CommandHandler { const requestResult = await requestCompletion( requestPrefix, context.agentContext, + direction, ); result.groups.push(...requestResult.groups); result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; - result.commitMode = requestResult.commitMode; } } return result; diff --git a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts index aef6e10b3f..ca35df90fd 100644 --- a/ts/packages/dispatcher/dispatcher/src/dispatcher.ts +++ b/ts/packages/dispatcher/dispatcher/src/dispatcher.ts @@ -243,8 +243,8 @@ export function createDispatcherFromContext( options, ); }, - getCommandCompletion(prefix) { - return getCommandCompletion(prefix, context); + getCommandCompletion(prefix, direction) { + return getCommandCompletion(prefix, direction, context); }, checkCache(request) { return checkCache(request, context, { diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index 9022434970..24a09cfd8c 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -5,6 +5,7 @@ import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import registerDebug from "debug"; import { ExecutableAction, getPropertyInfo, MatchOptions } from "agent-cache"; import { + CompletionDirection, CompletionGroup, CompletionGroups, TypeAgentAction, @@ -77,6 +78,7 @@ function getCompletionNamespaceKeys(context: CommandHandlerContext): string[] { export async function requestCompletion( requestPrefix: string, context: CommandHandlerContext, + direction?: CompletionDirection, ): Promise { debugCompletion(`Request completion for prefix: '${requestPrefix}'`); const namespaceKeys = getCompletionNamespaceKeys(context); @@ -89,7 +91,11 @@ export async function requestCompletion( namespaceKeys, history: getHistoryContext(context), }; - const results = context.agentCache.completion(requestPrefix, options); + const results = context.agentCache.completion( + requestPrefix, + options, + direction, + ); if (results === undefined) { return { groups: [] }; @@ -114,11 +120,6 @@ export async function requestCompletion( 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", }; } @@ -144,8 +145,6 @@ export async function requestCompletion( matchedPrefixLength, separatorMode, closedSet, - // Grammar completions use eager commit (see note above). - commitMode: "eager", }; } diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index a21a8d0d71..916f961125 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -496,6 +496,7 @@ describe("Command Completion - startIndex", () => { it("returns startIndex at suffix boundary for '@comptest run '", async () => { const result = await getCommandCompletion( "@comptest run ", + "forward", context, ); expect(result).toBeDefined(); @@ -513,14 +514,15 @@ describe("Command Completion - startIndex", () => { it("returns startIndex accounting for partial param for '@comptest run bu'", async () => { const result = await getCommandCompletion( "@comptest run bu", + "forward", context, ); expect(result).toBeDefined(); // "@comptest run bu" (16 chars) - // No trailing space → hasTrailingSpace=false. + // No trailing space → direction="forward". // suffix is "bu", parameter parsing fully consumes "bu". // lastCompletableParam="task", bare unquoted token, - // !hasTrailingSpace → exclusive path fires: backs up + // no trailing space → exclusive path fires: backs up // startIndex to the start of "bu" → 13. expect(result!.startIndex).toBe(13); // Agent IS invoked ("task" in agentCommandCompletions). @@ -531,6 +533,7 @@ describe("Command Completion - startIndex", () => { it("returns startIndex for nested command '@comptest nested sub '", async () => { const result = await getCommandCompletion( "@comptest nested sub ", + "forward", context, ); expect(result).toBeDefined(); @@ -546,6 +549,7 @@ describe("Command Completion - startIndex", () => { it("returns startIndex for partial flag '@comptest nested sub --ver'", async () => { const result = await getCommandCompletion( "@comptest nested sub --ver", + "forward", context, ); expect(result).toBeDefined(); @@ -561,7 +565,7 @@ describe("Command Completion - startIndex", () => { describe("empty and minimal input", () => { it("returns completions for empty input", async () => { - const result = await getCommandCompletion("", context); + const result = await getCommandCompletion("", "forward", context); expect(result).toBeDefined(); expect(result!.completions.length).toBeGreaterThan(0); // completions should include "@" @@ -576,13 +580,13 @@ describe("Command Completion - startIndex", () => { }); it("returns startIndex 0 for empty input", async () => { - const result = await getCommandCompletion("", context); + const result = await getCommandCompletion("", "forward", context); expect(result).toBeDefined(); expect(result!.startIndex).toBe(0); }); it("returns startIndex at end for whitespace-only input", async () => { - const result = await getCommandCompletion(" ", context); + const result = await getCommandCompletion(" ", "forward", context); expect(result).toBeDefined(); // " " normalizes to a command prefix with no suffix; // startIndex = input.length - suffix.length = 2, then @@ -593,7 +597,11 @@ describe("Command Completion - startIndex", () => { describe("agent name level", () => { it("returns subcommands at agent boundary '@comptest '", async () => { - const result = await getCommandCompletion("@comptest ", context); + const result = await getCommandCompletion( + "@comptest ", + "forward", + context, + ); expect(result).toBeDefined(); const subcommands = result!.completions.find( (g) => g.name === "Subcommands", @@ -611,7 +619,11 @@ describe("Command Completion - startIndex", () => { }); it("returns matching agent names for partial prefix '@com'", async () => { - const result = await getCommandCompletion("@com", context); + const result = await getCommandCompletion( + "@com", + "forward", + context, + ); // "@com" → normalizeCommand strips '@' → "com" // resolveCommand: "com" isn't an agent name → system agent, // system has no defaultSubCommand → descriptor=undefined, @@ -633,6 +645,7 @@ describe("Command Completion - startIndex", () => { it("returns completions for unknown agent with startIndex at '@'", async () => { const result = await getCommandCompletion( "@unknownagent ", + "forward", context, ); // "@unknownagent " → longest valid prefix is "@" @@ -655,6 +668,7 @@ describe("Command Completion - startIndex", () => { it("startIndex at token boundary with trailing space", async () => { const result = await getCommandCompletion( "@comptest run build ", + "forward", context, ); // "@comptest run build " (20 chars) @@ -672,6 +686,7 @@ describe("Command Completion - startIndex", () => { it("startIndex backs over whitespace before unconsumed remainder", async () => { const result = await getCommandCompletion( "@comptest run hello --unknown", + "forward", context, ); expect(result).toBeDefined(); @@ -686,6 +701,7 @@ describe("Command Completion - startIndex", () => { it("startIndex backs over multiple spaces before unconsumed remainder", async () => { const result = await getCommandCompletion( "@comptest run hello --unknown", + "forward", context, ); expect(result).toBeDefined(); @@ -700,7 +716,11 @@ describe("Command Completion - startIndex", () => { describe("separatorMode for command completions", () => { it("returns separatorMode for subcommand completions at agent boundary", async () => { - const result = await getCommandCompletion("@comptest ", context); + const result = await getCommandCompletion( + "@comptest ", + "forward", + context, + ); expect(result).toBeDefined(); // "run" is the default subcommand, so subcommand alternatives // are included and the group has separatorMode: "space". @@ -712,7 +732,11 @@ describe("Command Completion - startIndex", () => { }); it("returns separatorMode for resolved agent without trailing space", async () => { - const result = await getCommandCompletion("@comptest", context); + const result = await getCommandCompletion( + "@comptest", + "forward", + context, + ); expect(result).toBeDefined(); expect(result!.separatorMode).toBe("space"); // No trailing whitespace to trim — startIndex stays at end @@ -722,7 +746,7 @@ describe("Command Completion - startIndex", () => { }); it("does not set separatorMode at top level (@)", async () => { - const result = await getCommandCompletion("@", context); + const result = await getCommandCompletion("@", "forward", context); expect(result).toBeDefined(); // Top-level completions (agent names, system subcommands) // follow '@' — space is accepted but not required. @@ -742,6 +766,7 @@ describe("Command Completion - startIndex", () => { it("does not set separatorMode for parameter completions only", async () => { const result = await getCommandCompletion( "@comptest run bu", + "forward", context, ); expect(result).toBeDefined(); @@ -751,7 +776,11 @@ describe("Command Completion - startIndex", () => { }); it("returns no separatorMode for partial unmatched token consumed as param", async () => { - const result = await getCommandCompletion("@comptest ne", context); + const result = await getCommandCompletion( + "@comptest ne", + "forward", + context, + ); expect(result).toBeDefined(); // "ne" is fully consumed as the "task" arg by parameter // parsing. No trailing space → backs up to the start @@ -771,6 +800,7 @@ describe("Command Completion - startIndex", () => { it("returns empty completions for command with no parameters", async () => { const result = await getCommandCompletion( "@comptest noop ", + "forward", context, ); // "noop" has no parameters at all → nothing more to type. @@ -784,6 +814,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=true for flags-only command with no args unfilled", async () => { const result = await getCommandCompletion( "@comptest flagsonly ", + "forward", context, ); expect(result).toBeDefined(); @@ -801,6 +832,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=true for boolean flag pending", async () => { const result = await getCommandCompletion( "@comptest nested sub --verbose ", + "forward", context, ); expect(result).toBeDefined(); @@ -813,6 +845,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=false when agent completions are invoked without closedSet flag", async () => { const result = await getCommandCompletion( "@comptest run ", + "forward", context, ); expect(result).toBeDefined(); @@ -824,6 +857,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=true when agent returns closedSet=true", async () => { const result = await getCommandCompletion( "@comptest exhaustive ", + "forward", context, ); expect(result).toBeDefined(); @@ -839,6 +873,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=false when agent returns closedSet=false", async () => { const result = await getCommandCompletion( "@comptest nonexhaustive ", + "forward", context, ); expect(result).toBeDefined(); @@ -853,6 +888,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=false when agent does not set closedSet field", async () => { const result = await getCommandCompletion( "@comptest nocompletefield ", + "forward", context, ); expect(result).toBeDefined(); @@ -867,6 +903,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=false for unfilled positional args without agent", async () => { const result = await getCommandCompletion( "@comptest nested sub ", + "forward", context, ); expect(result).toBeDefined(); @@ -878,6 +915,7 @@ describe("Command Completion - startIndex", () => { it("closedSet=true for flags-only after one flag is set", async () => { const result = await getCommandCompletion( "@comptest flagsonly --debug true ", + "forward", context, ); expect(result).toBeDefined(); @@ -888,12 +926,13 @@ describe("Command Completion - startIndex", () => { it("returns flag names for non-boolean flag without trailing space", async () => { const result = await getCommandCompletion( "@comptest flagsonly --level", + "backward", 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. + // "--level" is a recognized number flag. With + // direction="backward" (user reconsidering), offer flag + // names at 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", @@ -906,6 +945,7 @@ describe("Command Completion - startIndex", () => { it("treats unrecognized flag prefix as filter text", async () => { const result = await getCommandCompletion( "@comptest flagsonly --lev", + "forward", context, ); // "--lev" doesn't resolve (exact match only), so parseParams @@ -925,7 +965,11 @@ describe("Command Completion - startIndex", () => { describe("flat descriptor (no subcommand table)", () => { it("returns parameter completions for flat agent", async () => { - const result = await getCommandCompletion("@flattest ", context); + const result = await getCommandCompletion( + "@flattest ", + "forward", + context, + ); // flattest has no subcommand table (table===undefined), // but its descriptor has parameters (args + flags). // Should return flag completions. @@ -941,6 +985,7 @@ describe("Command Completion - startIndex", () => { it("returns correct startIndex for flat agent with partial token", async () => { const result = await getCommandCompletion( "@flattest --rel", + "forward", context, ); // "@flattest --rel" (15 chars) @@ -951,7 +996,11 @@ describe("Command Completion - startIndex", () => { }); it("falls back to system for agent with no commands", async () => { - const result = await getCommandCompletion("@nocmdtest ", context); + const result = await getCommandCompletion( + "@nocmdtest ", + "forward", + context, + ); // nocmdtest has no getCommands → not command-enabled → // resolveCommand falls back to system agent. System has // a subcommand table, so we get system subcommands. @@ -974,6 +1023,7 @@ describe("Command Completion - startIndex", () => { it("drops subcommands when default command parameter is filled", async () => { const result = await getCommandCompletion( "@comptest build ", + "forward", context, ); // "@comptest build " (16 chars) @@ -993,7 +1043,11 @@ describe("Command Completion - startIndex", () => { }); it("keeps subcommands when at the command boundary", async () => { - const result = await getCommandCompletion("@comptest ", context); + const result = await getCommandCompletion( + "@comptest ", + "forward", + context, + ); // "@comptest " (10 chars) // Resolves to default "run" — suffix is empty, parameter // startIndex equals commandBoundary → subcommands included. @@ -1006,7 +1060,11 @@ describe("Command Completion - startIndex", () => { }); it("includes subcommands when no trailing space at default command", async () => { - const result = await getCommandCompletion("@comptest ne", context); + const result = await getCommandCompletion( + "@comptest ne", + "forward", + 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 @@ -1023,6 +1081,7 @@ describe("Command Completion - startIndex", () => { it("backs startIndex to open-quote token start for '@comptest run \"bu'", async () => { const result = await getCommandCompletion( '@comptest run "bu', + "forward", context, ); // '@comptest run "bu' (17 chars) @@ -1044,6 +1103,7 @@ describe("Command Completion - startIndex", () => { it("backs startIndex for multi-arg open quote '@comptest twoarg \"partial'", async () => { const result = await getCommandCompletion( '@comptest twoarg "partial', + "forward", context, ); // '@comptest twoarg "partial' (25 chars) @@ -1060,6 +1120,7 @@ describe("Command Completion - startIndex", () => { it("backs startIndex for implicitQuotes '@comptest search hello world'", async () => { const result = await getCommandCompletion( "@comptest search hello world", + "forward", context, ); // "@comptest search hello world" (28 chars) @@ -1075,6 +1136,7 @@ describe("Command Completion - startIndex", () => { it("does not adjust startIndex for fully-quoted token", async () => { const result = await getCommandCompletion( '@comptest run "build"', + "forward", context, ); // '@comptest run "build"' (21 chars) @@ -1089,6 +1151,7 @@ describe("Command Completion - startIndex", () => { it("adjusts startIndex for bare unquoted token without trailing space", async () => { const result = await getCommandCompletion( "@comptest run bu", + "forward", context, ); // "bu" is not quoted → isFullyQuoted returns undefined. @@ -1104,6 +1167,7 @@ describe("Command Completion - startIndex", () => { it("open-quote CJK advances startIndex by matchedPrefixLength", async () => { const result = await getCommandCompletion( '@comptest grammar "東京タ', + "forward", context, ); // '@comptest grammar "東京タ' (22 chars) @@ -1130,6 +1194,7 @@ describe("Command Completion - startIndex", () => { it("implicitQuotes CJK advances startIndex by matchedPrefixLength", async () => { const result = await getCommandCompletion( "@comptest grammariq 東京タ", + "forward", context, ); // "@comptest grammariq 東京タ" (23 chars) @@ -1156,6 +1221,7 @@ describe("Command Completion - startIndex", () => { it("fully-quoted token does not invoke grammar", async () => { const result = await getCommandCompletion( '@comptest grammar "東京タ"', + "forward", context, ); // Token '"東京タ"' is fully quoted → isFullyQuoted = true. @@ -1174,11 +1240,12 @@ describe("Command Completion - startIndex", () => { it("bare unquoted token invokes grammar without trailing space", async () => { const result = await getCommandCompletion( "@comptest grammar 東京タ", + "forward", context, ); // "東京タ" has no quotes and no trailing space. // lastCompletableParam exclusive path fires - // (!hasTrailingSpace && pendingFlag === undefined). + // (no trailing space && pendingFlag === undefined). // Agent is invoked with grammar mock → matches "東京" → // returns matchedPrefixLength=2. tokenStartIndex = 21-3 = 18, // startIndex = 18 + 2 = 20. @@ -1195,6 +1262,7 @@ describe("Command Completion - startIndex", () => { it("trailing space without text offers initial completions", async () => { const result = await getCommandCompletion( "@comptest grammar ", + "forward", context, ); // "@comptest grammar " (18 chars) @@ -1216,6 +1284,7 @@ describe("Command Completion - startIndex", () => { it("clears earlier completions when matchedPrefixLength is set", async () => { const result = await getCommandCompletion( '@comptest grammar "東京タ', + "forward", context, ); // When groupPrefixLength fires, parameter/flag @@ -1231,6 +1300,7 @@ describe("Command Completion - startIndex", () => { // No trailing space → backs up to start of "bu". const result = await getCommandCompletion( "@comptest run bu", + "forward", context, ); expect(result.startIndex).toBe(13); @@ -1239,6 +1309,7 @@ describe("Command Completion - startIndex", () => { it("English prefix with space separator", async () => { const result = await getCommandCompletion( "@comptest grammariq Tokyo T", + "forward", context, ); // "@comptest grammariq Tokyo T" (27 chars) @@ -1265,6 +1336,7 @@ describe("Command Completion - startIndex", () => { it("completed CJK match returns no completions", async () => { const result = await getCommandCompletion( "@comptest grammariq 東京タワー", + "forward", context, ); // Token = "東京タワー" (5 chars). Mock matches "東京" @@ -1282,6 +1354,7 @@ describe("Command Completion - startIndex", () => { it("no-text offers initial completions via grammariq", async () => { const result = await getCommandCompletion( "@comptest grammariq ", + "forward", context, ); // "@comptest grammariq " (20 chars) @@ -1311,6 +1384,7 @@ describe("Command Completion - startIndex", () => { it("startIndex at tokenBoundary for '@comptest nested sub val' (no agent getCommandCompletion)", async () => { const result = await getCommandCompletion( "@comptest nested sub val", + "forward", context, ); // "@comptest nested sub val" (24 chars) @@ -1319,7 +1393,7 @@ describe("Command Completion - startIndex", () => { // "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"]. + // no trailing space, 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); @@ -1328,6 +1402,7 @@ describe("Command Completion - startIndex", () => { it("startIndex at tokenBoundary for '@comptest nested sub --verbose val' (no agent getCommandCompletion)", async () => { const result = await getCommandCompletion( "@comptest nested sub --verbose val", + "forward", context, ); // "@comptest nested sub --verbose val" (33 chars) @@ -1351,6 +1426,7 @@ describe("Command Completion - startIndex", () => { it("does not back up over number arg for '@numstrtest numstr 42' (no trailing space)", async () => { const result = await getCommandCompletion( "@numstrtest numstr 42", + "forward", context, ); // "@numstrtest numstr 42" (21 chars) @@ -1374,10 +1450,11 @@ describe("Command Completion - startIndex", () => { it("baseline: '@numstrtest numstr 42 ' with trailing space works correctly", async () => { const result = await getCommandCompletion( "@numstrtest numstr 42 ", + "forward", context, ); // "@numstrtest numstr 42 " (22 chars) - // Trailing space → hasTrailingSpace=true, fallback never + // Trailing space → direction="forward", fallback never // fires. startIndex = tokenBoundary(input, 22) = 21 // (rewinds over trailing space to end of "42"). // Agent invoked for "name" completions. @@ -1392,12 +1469,13 @@ describe("Command Completion - startIndex", () => { 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", + "forward", 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): + // Exclusive path fires (bare token, no trailing space): // backs up to before "al" → tokenBoundary(input, 22) = 21. // Agent invoked for "name". expect(result.startIndex).toBe(21); @@ -1408,4 +1486,116 @@ describe("Command Completion - startIndex", () => { expect(result.closedSet).toBe(false); }); }); + + describe("backward direction", () => { + it("backs up to subcommand alternatives for '@comptest run' backward", async () => { + // "run" is a valid subcommand of @comptest; with backward + // direction the user is reconsidering the subcommand choice. + const result = await getCommandCompletion( + "@comptest run", + "backward", + context, + ); + // startIndex backs up to the start of "run" in the + // input (position 10, after "@comptest "). + expect(result.startIndex).toBe(10); + const subcommands = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeDefined(); + expect(subcommands!.completions).toContain("run"); + expect(subcommands!.completions).toContain("nested"); + expect(subcommands!.completions).toContain("noop"); + // Subcommand names are exhaustive. + expect(result.closedSet).toBe(true); + }); + + it("does not back up with trailing space '@comptest run ' backward", async () => { + // Trailing space means the user already committed "run", + // so backward doesn't trigger uncommittedCommand; parameter + // completions are offered instead. + const result = await getCommandCompletion( + "@comptest run ", + "backward", + context, + ); + // startIndex should be at the parameter boundary (13, + // end of "run"), not backed up to subcommand level. + expect(result.startIndex).toBe(13); + const subcommands = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeUndefined(); + }); + + it("backs up to nested subcommand alternatives for '@comptest nested sub' backward", async () => { + const result = await getCommandCompletion( + "@comptest nested sub", + "backward", + context, + ); + // "sub" is a valid subcommand of "nested"; backward + // should back up to the start of "sub" (position 17, + // after "@comptest nested "). + expect(result.startIndex).toBe(17); + const subcommands = result.completions.find( + (g) => g.name === "Subcommands", + ); + expect(subcommands).toBeDefined(); + expect(subcommands!.completions).toContain("sub"); + }); + + it("boolean flag '@comptest flagsonly --debug' backward does not back up (boolean consumed)", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --debug", + "backward", + context, + ); + // "--debug" is a boolean flag — it has no pending value, + // so the backward flag-backtrack path (pendingFlag) is + // not triggered. startIndex is at the end of the input. + expect(result.startIndex).toBe(27); + }); + + it("backs up to flag alternatives for non-boolean flag '@comptest flagsonly --level' backward", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --level", + "backward", + context, + ); + // "--level" is a non-boolean flag (its value is pending). + // Backward backs up to the flag token start. + const flags = result.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeDefined(); + expect(flags!.completions).toContain("--debug"); + expect(flags!.completions).toContain("--level"); + }); + + it("forward on '@comptest run' offers parameter completions", async () => { + // Contrast with the backward test above: forward on a + // resolved subcommand without trailing space should still + // offer parameters (task completions from the agent). + const result = await getCommandCompletion( + "@comptest run", + "forward", + context, + ); + // With forward, startIndex is at end of "run" (13) and + // parameter/agent completions are offered. + expect(result.startIndex).toBe(13); + // Forward still includes subcommand alternatives since + // the default subcommand was resolved. + expect(result.closedSet).toBe(false); + }); + + it("empty input backward does not backtrack", async () => { + // Empty input with backward shouldn't crash; normalizeCommand + // generates implicit tokens that are "normalizedCommitted". + const result = await getCommandCompletion("", "backward", context); + expect(result).toBeDefined(); + expect(result.completions.length).toBeGreaterThan(0); + }); + }); }); diff --git a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts index dae164a648..48977368cf 100644 --- a/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts +++ b/ts/packages/dispatcher/rpc/src/dispatcherTypes.ts @@ -14,6 +14,7 @@ import type { DispatcherStatus, ProcessCommandOptions, } from "@typeagent/dispatcher-types"; +import type { CompletionDirection } from "@typeagent/agent-sdk"; export type DispatcherInvokeFunctions = { processCommand( @@ -41,7 +42,10 @@ export type DispatcherInvokeFunctions = { propertyName: string, ): Promise; - getCommandCompletion(prefix: string): Promise; + getCommandCompletion( + prefix: string, + direction: CompletionDirection, + ): Promise; checkCache(request: string): Promise; diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index edc6f2b52c..e77177e894 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { - CommitMode, + CompletionDirection, CompletionGroup, DisplayType, DynamicDisplay, @@ -89,13 +89,6 @@ export type CommandCompletionResult = { // 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 = { @@ -200,7 +193,10 @@ export interface Dispatcher { ): Promise; // APIs to get command completion for intellisense like functionality. - getCommandCompletion(prefix: string): Promise; + getCommandCompletion( + prefix: string, + direction: CompletionDirection, + ): Promise; // Check if a request can be handled by cache without executing checkCache(request: string): Promise; diff --git a/ts/packages/shell/src/renderer/src/partial.ts b/ts/packages/shell/src/renderer/src/partial.ts index f9c0af6024..c26c579f9d 100644 --- a/ts/packages/shell/src/renderer/src/partial.ts +++ b/ts/packages/shell/src/renderer/src/partial.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { Dispatcher } from "agent-dispatcher"; +import { CompletionDirection } from "@typeagent/agent-sdk"; import { SearchMenu } from "./search"; import { SearchMenuItem } from "./searchMenuUI/searchMenuUI"; import { @@ -52,6 +53,9 @@ export class PartialCompletion { private readonly searchMenu: SearchMenu; private readonly session: PartialCompletionSession; public closed: boolean = false; + // Track previous input to determine direction: shorter = backspace + // ("backward"), longer/same = forward action. + private previousInput: string = ""; private readonly cleanupEventListeners: () => void; constructor( @@ -111,8 +115,19 @@ export class PartialCompletion { const input = this.getCurrentInputForCompletion(); debug(`Partial completion input: '${input}'`); - this.session.update(input, (prefix) => - this.getSearchMenuPosition(prefix), + // Only use "backward" when the user is genuinely backspacing: + // the new input must be a strict prefix of the previous input. + const direction: CompletionDirection = + input.length < this.previousInput.length && + this.previousInput.startsWith(input) + ? "backward" + : "forward"; + this.previousInput = input; + + this.session.update( + input, + (prefix) => this.getSearchMenuPosition(prefix), + direction, ); } @@ -308,8 +323,9 @@ export class PartialCompletion { debug(`Partial completion replaced: ${replaceText}`); - // Explicitly trigger a completion update. The selectionchange event - // alone is unreliable after programmatic DOM manipulation. + // Clear previousInput so auto-detection picks "forward" for the + // post-selection update (the new input won't be a prefix of ""). + this.previousInput = ""; this.update(false); } diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index f17907d5b2..92b8befec5 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -3,7 +3,7 @@ import { CommandCompletionResult } from "agent-dispatcher"; import { - CommitMode, + CompletionDirection, CompletionGroup, SeparatorMode, } from "@typeagent/agent-sdk"; @@ -28,7 +28,10 @@ export interface ISearchMenu { } export interface ICompletionDispatcher { - getCommandCompletion(input: string): Promise; + getCommandCompletion( + input: string, + direction: CompletionDirection, + ): Promise; } // PartialCompletionSession manages the state machine for command completion. @@ -50,16 +53,9 @@ export interface ICompletionDispatcher { // 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. +// entry (and it is not a prefix of any other). Always re-fetches +// for the NEXT level's completions — the direction to use for the +// re-fetch is determined by the caller. // - 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) @@ -79,7 +75,6 @@ export class PartialCompletionSession { // 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; @@ -91,17 +86,19 @@ export class PartialCompletionSession { // Main entry point. Called by PartialCompletion.update() after DOM checks pass. // input: trimmed input text (ghost text stripped, leading whitespace stripped) + // direction: host-provided signal: \"forward\" (user is moving ahead) or\n // \"backward\" (user is reconsidering, e.g. backspaced) // 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, + direction: CompletionDirection = "forward", ): void { if (this.reuseSession(input, getPosition)) { return; } - this.startNewSession(input, getPosition); + this.startNewSession(input, getPosition, direction); } // Hide the menu and cancel any in-flight fetch, but preserve session @@ -151,8 +148,6 @@ export class PartialCompletionSession { // (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 @@ -172,14 +167,10 @@ export class PartialCompletionSession { // 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. + // the NEXT level's completions. // 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. + // is not a prefix of any other. Always re-fetch + // for the NEXT level. // 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 @@ -208,7 +199,7 @@ export class PartialCompletionSession { } // ACTIVE from here. - const { anchor, separatorMode: sepMode, closedSet, commitMode } = this; + const { anchor, separatorMode: sepMode, closedSet } = this; // [A2] RE-FETCH — input moved past the anchor (e.g. backspace, new word). if (!input.startsWith(anchor)) { @@ -280,20 +271,13 @@ export class PartialCompletionSession { // [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. + // Always re-fetch for the next level — the direction + // for the re-fetch comes from the caller. 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`, + `Partial completion re-fetch: '${completionPrefix}' uniquely satisfied`, ); - return true; // REUSE — wait for explicit separator before re-fetching + return false; // RE-FETCH (hierarchical navigation) } // [B5] Committed-past-boundary: the prefix contains whitespace @@ -337,15 +321,18 @@ export class PartialCompletionSession { private startNewSession( input: string, getPosition: (prefix: string) => SearchMenuPosition | undefined, + direction: CompletionDirection, ): void { - debug(`Partial completion start: '${input}'`); + debug(`Partial completion start: '${input}' direction=${direction}`); this.menu.hide(); this.menu.setChoices([]); this.anchor = input; this.separatorMode = "space"; this.closedSet = false; - this.commitMode = "explicit"; - const completionP = this.dispatcher.getCommandCompletion(input); + const completionP = this.dispatcher.getCommandCompletion( + input, + direction, + ); this.completionP = completionP; completionP .then((result) => { @@ -359,7 +346,6 @@ export class PartialCompletionSession { this.separatorMode = result.separatorMode ?? "space"; this.closedSet = result.closedSet; - this.commitMode = result.commitMode ?? "explicit"; const completions = toMenuItems(result.completions); diff --git a/ts/packages/shell/test/partialCompletion/commitMode.spec.ts b/ts/packages/shell/test/partialCompletion/direction.spec.ts similarity index 73% rename from ts/packages/shell/test/partialCompletion/commitMode.spec.ts rename to ts/packages/shell/test/partialCompletion/direction.spec.ts index acffed51b5..52ba7cb5b5 100644 --- a/ts/packages/shell/test/partialCompletion/commitMode.spec.ts +++ b/ts/packages/shell/test/partialCompletion/direction.spec.ts @@ -9,12 +9,16 @@ import { getPos, } from "./helpers.js"; -// ── commitMode ──────────────────────────────────────────────────────────────── - -describe("PartialCompletionSession — commitMode", () => { - test("commitMode=explicit (default): uniquely satisfied does NOT re-fetch", async () => { +// ── direction-based completion ──────────────────────────────────────────────── +// +// The direction parameter ("forward" or "backward") resolves structural +// ambiguity when the input is valid. "forward" means the user is moving +// ahead; "backward" means they're reconsidering. B4 (uniquely satisfied) +// always triggers a re-fetch regardless of direction. + +describe("PartialCompletionSession — direction-based completion", () => { + test("uniquely satisfied always triggers re-fetch", async () => { const menu = makeMenu(); - // Default commitMode (omitted → "explicit") const result = makeCompletionResult(["song"], 4, { separatorMode: "space", closedSet: true, @@ -27,56 +31,31 @@ describe("PartialCompletionSession — commitMode", () => { 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); + // "song" uniquely matched — B4 fires immediately expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( - "play song ", + "play song", + "forward", ); }); - test("commitMode=eager: uniquely satisfied triggers immediate re-fetch", async () => { + test("direction parameter is forwarded to dispatcher", 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", + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "play ", + "forward", ); }); - test("commitMode=explicit: B5 committed-past-boundary still fires", async () => { + test("B5 committed-past-boundary still fires", async () => { const menu = makeMenu(); const result = makeCompletionResult(["set", "setWindowState"], 4, { separatorMode: "space", @@ -93,10 +72,11 @@ describe("PartialCompletionSession — commitMode", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "play set ", + "forward", ); }); - test("commitMode=explicit: open-set no-matches still triggers re-fetch (C6 unaffected)", async () => { + test("open-set no-matches still triggers re-fetch (C6 unaffected)", async () => { const menu = makeMenu(); const result = makeCompletionResult(["song"], 4, { separatorMode: "space", @@ -114,9 +94,21 @@ describe("PartialCompletionSession — commitMode", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); }); - test("commitMode defaults to explicit when omitted from result", async () => { + test("backward direction is forwarded to dispatcher on new session", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "play", + "backward", + ); + }); + + test("backward direction is forwarded on re-fetch after unique match", async () => { const menu = makeMenu(); - // No commitMode in result — defaults to "explicit" const result = makeCompletionResult(["song"], 4, { separatorMode: "space", closedSet: true, @@ -125,34 +117,63 @@ describe("PartialCompletionSession — commitMode", () => { const session = new PartialCompletionSession(menu, dispatcher); session.update("play ", getPos); - await Promise.resolve(); + await Promise.resolve(); // → ACTIVE, anchor = "play" - session.update("play song", getPos); + // Uniquely satisfied → re-fetch; backward direction forwarded + session.update("play song", getPos, "backward"); - // Default commitMode="explicit" — B4 suppressed - expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play song", + "backward", + ); }); - test("commitMode=explicit + closedSet=false: uniquely satisfied does NOT re-fetch", async () => { + test("backward direction is forwarded on anchor-divergence 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(); + await Promise.resolve(); // → ACTIVE, anchor = "play" - // "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); + // Backspace past anchor — anchor diverged, triggers new session + session.update("pla", getPos, "backward"); - // Only after typing a separator should B5 trigger a re-fetch - session.update("play song ", getPos); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "pla", + "backward", + ); + }); + + test("backward on IDLE starts new session with backward", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play music", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "play music", + "backward", + ); + }); + + test("default direction is forward when omitted", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "play", + "forward", + ); }); }); @@ -179,6 +200,7 @@ describe("PartialCompletionSession — committed-past-boundary re-fetch", () => expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "play set ", + "forward", ); }); diff --git a/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts b/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts index 8e357f6101..4adabebb3c 100644 --- a/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts +++ b/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts @@ -34,6 +34,7 @@ describe("PartialCompletionSession — backend error handling", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "stop", + "forward", ); }); diff --git a/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts b/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts index 63a547df79..0108cf02fa 100644 --- a/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts +++ b/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts @@ -132,6 +132,7 @@ describe("PartialCompletionSession — @command routing", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( "@config ", + "forward", ); }); @@ -146,6 +147,7 @@ describe("PartialCompletionSession — @command routing", () => { // correct startIndex; no word-boundary truncation needed. expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( "@config c", + "forward", ); }); @@ -156,7 +158,10 @@ describe("PartialCompletionSession — @command routing", () => { session.update("@config", getPos); - expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith("@config"); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "@config", + "forward", + ); }); test("@ command in PENDING state does not re-fetch", () => { @@ -250,6 +255,7 @@ describe("PartialCompletionSession — @command routing", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "@unknow", + "forward", ); }); }); diff --git a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts index d859e273f6..2f7416913e 100644 --- a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts +++ b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts @@ -131,6 +131,7 @@ describe("PartialCompletionSession — separatorMode: spacePunctuation", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "playx", + "forward", ); }); @@ -229,6 +230,7 @@ describe("PartialCompletionSession — separatorMode edge cases", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "play2", + "forward", ); }); diff --git a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts index 64c0fff8d6..e1268b3442 100644 --- a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts +++ b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts @@ -84,7 +84,6 @@ describe("Pattern A — startIndex before separator (separatorMode=spacePunctuat const result = makeCompletionResult(["Rock", "Jazz", "Blues"], 4, { separatorMode: "spacePunctuation", closedSet: false, - commitMode: "eager", }); test("space after anchor satisfies separator — shows all completions", async () => { @@ -179,7 +178,6 @@ describe("Pattern B — startIndex past separator (separatorMode=none)", () => { const result = makeCompletionResult(["Rock", "Jazz", "Blues"], 5, { separatorMode: "none", closedSet: false, - commitMode: "eager", }); test("letter after anchor goes straight to trie (no separator needed)", async () => { @@ -238,7 +236,6 @@ describe("Pattern B variant — startIndex past separator (separatorMode=optiona const result = makeCompletionResult(["Rock", "Jazz", "Blues"], 5, { separatorMode: "optional", closedSet: false, - commitMode: "eager", }); test("letter after anchor filters via trie (no separator needed)", async () => { @@ -276,7 +273,6 @@ describe("Double separator — startIndex past separator + separatorMode requiri const doubleSepResult = makeCompletionResult(["Rock", "Jazz", "Blues"], 5, { separatorMode: "spacePunctuation", closedSet: false, - commitMode: "eager", }); test("double space satisfies the separator — trie filters correctly", async () => { diff --git a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts index 33eadc8634..b3173c4b9a 100644 --- a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts +++ b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts @@ -22,7 +22,10 @@ describe("PartialCompletionSession — state transitions", () => { session.update("play", getPos); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); - expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith("play"); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "play", + "forward", + ); }); test("PENDING: second update while promise is in-flight does not re-fetch", () => { @@ -85,7 +88,10 @@ describe("PartialCompletionSession — state transitions", () => { session.update("pla", getPos); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); - expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith("pla"); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "pla", + "forward", + ); }); test("ACTIVE → hide+keep: closedSet=true, trie has no matches — no re-fetch", async () => { @@ -123,6 +129,7 @@ describe("PartialCompletionSession — state transitions", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "play xyz", + "forward", ); }); @@ -180,6 +187,7 @@ describe("PartialCompletionSession — state transitions", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "stop", + "forward", ); }); @@ -221,7 +229,10 @@ describe("PartialCompletionSession — state transitions", () => { session.update("", getPos); await Promise.resolve(); - expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith(""); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "", + "forward", + ); expect(menu.setChoices).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ selectedText: "@" }), @@ -245,11 +256,10 @@ describe("PartialCompletionSession — state transitions", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); }); - test("empty input: unique match triggers re-fetch (commitMode=eager)", async () => { + test("empty input: unique match triggers re-fetch", async () => { const menu = makeMenu(); const result = makeCompletionResult(["@"], 0, { separatorMode: "none", - commitMode: "eager", }); const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -261,17 +271,19 @@ describe("PartialCompletionSession — state transitions", () => { // "@" uniquely matches the only completion — triggers re-fetch expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); - expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith("@"); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "@", + "forward", + ); }); - test("empty input: unique match triggers re-fetch even when closedSet=true (commitMode=eager)", async () => { + test("empty input: unique match triggers re-fetch even when closedSet=true", 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); @@ -282,7 +294,10 @@ describe("PartialCompletionSession — state transitions", () => { session.update("@", getPos); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); - expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith("@"); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "@", + "forward", + ); }); test("empty input: ambiguous prefix does not re-fetch", async () => { From e05a2eb9302eef1dcae7673bda1736be2a55ac3e Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 17 Mar 2026 17:27:33 -0700 Subject: [PATCH 2/7] Fix backward completion edge cases and refactor grammar matcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grammar matcher: - Fix empty input backward returning no completions: emitBackwardCompletion now returns false when there's nothing to back up to, allowing the caller to fall through to forward behavior - Generalize lastStringPartInfo → lastMatchedPartInfo to track both string and varNumber parts, enabling backward completion to back up to number slots with property completions - Refactor duplicate code across Categories 1-3 into three helpers: emitPropertyCompletion, emitStringCompletion, emitBackwardCompletion - Add tests for empty input backward, varNumber backward, and forward Dispatcher completion: - Fix separatorMode incorrectly set to 'optional' when suffix exists (e.g. '@config agent --off'): the space between command and first parameter is structural, not trailing - Agent separatorMode now only overrides when the agent advances startIndex via matchedPrefixLength (authoritative position), preserving the target-level separatorMode otherwise --- .../actionGrammar/src/grammarMatcher.ts | 495 +++++++-------- .../grammarCompletionLongestMatch.spec.ts | 17 +- .../grammarCompletionPrefixLength.spec.ts | 588 +++++++++++++++++- .../src/constructions/constructionCache.ts | 19 +- ts/packages/cache/test/completion.spec.ts | 137 +++- .../dispatcher/src/command/completion.ts | 140 +++-- .../dispatcher/test/completion.spec.ts | 179 +++--- 7 files changed, 1104 insertions(+), 471 deletions(-) diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 794dae3792..593c1e257f 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -238,11 +238,20 @@ type MatchState = { } | undefined; - // Completion support: start index and part reference for the last - // matched string part. Used by backward completion to back up to - // the last literal word. - lastStringPartInfo?: - | { readonly start: number; readonly part: StringPart } + // Completion support: tracks the last matched non-wildcard part + // (string or number). Used by backward completion to back up to + // the most recently matched item. + lastMatchedPartInfo?: + | { + readonly type: "string"; + readonly start: number; + readonly part: StringPart; + } + | { + readonly type: "number"; + readonly start: number; + readonly valueId: number; + } | undefined; }; @@ -566,6 +575,24 @@ function nextNonSeparatorIndex(request: string, index: number) { return match === null ? index : index + match[0].length; } +// When `index` is followed only by separator characters (whitespace / +// punctuation) until end-of-string, return `text.length` so that the +// trailing separators are included in the consumed prefix. Otherwise +// return `index` unchanged. +// +// This makes completion trailing-space-sensitive: "play music " reports +// matchedPrefixLength=11 (including the space) instead of 10. The +// dispatcher no longer strips trailing whitespace, so the grammar must +// include it when the user has already typed it. +function consumeTrailingSeparators(text: string, index: number): number { + if (index >= text.length) { + return index; + } + return nextNonSeparatorIndex(text, index) >= text.length + ? text.length + : index; +} + // Finalize the state to capture the last wildcard if any // and make sure to reject any trailing un-matched non-separator characters. function finalizeState(state: MatchState, request: string) { @@ -738,7 +765,11 @@ function matchStringPartWithWildcard( } if (captureWildcard(state, request, wildcardEnd, newIndex, pending)) { - state.lastStringPartInfo = { start: wildcardEnd, part }; + state.lastMatchedPartInfo = { + type: "string", + start: wildcardEnd, + part, + }; debugMatch( state, `Matched string '${part.value.join(" ")}' at ${wildcardEnd}`, @@ -784,7 +815,7 @@ function matchStringPartWithoutWildcard( // default string part value addValue(state, undefined, part.value.join(" ")); } - state.lastStringPartInfo = { start: curr, part }; + state.lastMatchedPartInfo = { type: "string", start: curr, part }; state.index = newIndex; return true; } @@ -882,7 +913,15 @@ function matchVarNumberPartWithWildcard( `Matched number at ${wildcardEnd} to ${newIndex}`, ); - addValue(state, part.variable, n); + const valueId = addValueId(state, part.variable); + if (valueId !== undefined) { + addValueWithId(state, valueId, n, false); + state.lastMatchedPartInfo = { + type: "number", + start: wildcardEnd, + valueId, + }; + } return true; } debugMatch( @@ -926,7 +965,15 @@ function matchVarNumberPartWithoutWildcard( debugMatch(state, `Matched number to ${newIndex}`); - addValue(state, part.variable, n); + const valueId = addValueId(state, part.variable); + if (valueId !== undefined) { + addValueWithId(state, valueId, n, false); + state.lastMatchedPartInfo = { + type: "number", + start: curr, + valueId, + }; + } state.index = newIndex; return true; } @@ -1227,19 +1274,24 @@ function tryPartialStringMatch( matchedWords++; } - if (direction === "backward") { - // Back up to the last matched word. If no words matched - // there is nothing to back up to. - if (matchedWords === 0) { - return undefined; - } + if ( + direction === "backward" && + matchedWords > 0 && + // Back up only when no trailing separator commits the + // last matched word. In "none" mode there are no + // separators. Otherwise, if separator characters exist + // beyond the match (nextNonSeparatorIndex > index), the + // separator commits the word — fall through to forward. + (spacingMode === "none" || + nextNonSeparatorIndex(prefix, index) === index) + ) { return { consumedLength: prevIndex, remainingText: words[matchedWords - 1], }; } - - // Forward (default): offer the next unmatched word. + // Forward (default), or backward with no words fully matched + // (nothing to reconsider — e.g. input "pl" still offers "play"). // Return undefined when all words matched (exact match). if (matchedWords >= words.length) { return undefined; @@ -1311,7 +1363,9 @@ export function matchGrammarCompletion( minPrefixLength?: number, direction?: "forward" | "backward", ): GrammarCompletionResult { - debugCompletion(`Start completion for prefix: "${prefix}"`); + debugCompletion( + `Start completion for prefix ${direction ?? "forward"}: "${prefix}"`, + ); // Seed the work-list with one MatchState per top-level grammar rule. // matchState may push additional states (for nested rules, optional @@ -1353,6 +1407,123 @@ export function matchGrammarCompletion( } } + // Helper: emit a wildcard/entity property completion at a given + // prefix position. Updates maxPrefixLength, separatorMode, and + // closedSet. + function emitPropertyCompletion( + state: MatchState, + valueId: number, + prefixPosition: number, + ): void { + const completionProperty = getGrammarCompletionProperty(state, valueId); + if (completionProperty === undefined) return; + updateMaxPrefixLength(prefixPosition); + if (prefixPosition !== maxPrefixLength) return; + properties.push(completionProperty); + closedSet = false; + let candidateNeedsSep = false; + if (prefixPosition > 0 && state.spacingMode !== "none") { + candidateNeedsSep = requiresSeparator( + prefix[prefixPosition - 1], + "a", + state.spacingMode, + ); + } + separatorMode = mergeSeparatorMode( + separatorMode, + candidateNeedsSep, + state.spacingMode, + ); + } + + // Helper: emit a literal string completion at a given prefix + // position. Updates maxPrefixLength and separatorMode. + function emitStringCompletion( + state: MatchState, + candidatePrefixLength: number, + completionText: string, + ): void { + updateMaxPrefixLength(candidatePrefixLength); + if (candidatePrefixLength !== maxPrefixLength) return; + 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, + ); + } + + // Helper: backward completion — back up to the last matched item + // (wildcard, literal word, or number). If a wildcard was captured + // after the last matched part, prefer it; otherwise back up to + // the last matched part via tryPartialStringMatch (for strings) + // or emitPropertyCompletion (for numbers). + function emitBackwardCompletion( + state: MatchState, + savedWildcard: typeof savedPendingWildcard, + ): boolean { + const wildcardStart = savedWildcard?.start; + const partStart = state.lastMatchedPartInfo?.start; + if ( + savedWildcard !== undefined && + savedWildcard.valueId !== undefined && + (partStart === undefined || + (wildcardStart !== undefined && wildcardStart >= partStart)) + ) { + emitPropertyCompletion( + state, + savedWildcard.valueId, + savedWildcard.start, + ); + return true; + } else if (state.lastMatchedPartInfo !== undefined) { + const info = state.lastMatchedPartInfo; + if (info.type === "string") { + const backResult = tryPartialStringMatch( + info.part, + prefix, + info.start, + state.spacingMode, + "backward", + ); + if (backResult !== undefined) { + emitStringCompletion( + state, + backResult.consumedLength, + backResult.remainingText, + ); + } else { + updateMaxPrefixLength(state.index); + } + } else { + // Number part — offer property completion for the + // number slot so the user can re-enter a value. + emitPropertyCompletion(state, info.valueId, info.start); + } + return true; + } + // Nothing to back up to — caller should fall through to + // forward behavior. + return false; + } + + // Keep savedPendingWildcard type available for the helper above. + let savedPendingWildcard: + | { readonly start: number; readonly valueId: number | undefined } + | undefined; + // --- Main loop: process every pending state --- while (pending.length > 0) { const state = pending.pop()!; @@ -1368,7 +1539,7 @@ export function matchGrammarCompletion( // Save the pending wildcard before finalizeState clears it. // Needed for backward completion of wildcards at the end of a rule. - const savedPendingWildcard = state.pendingWildcard; + savedPendingWildcard = state.pendingWildcard; // finalizeState does two things: // 1. If a wildcard is pending at the end, attempt to capture @@ -1381,104 +1552,13 @@ export function matchGrammarCompletion( // --- Category 1: Exact match --- // All parts matched AND prefix was fully consumed. if (matched) { - if (direction === "backward") { - // The user is backing up — offer alternatives for - // the last wildcard instead of advancing. Use - // savedPendingWildcard only — it is set when the - // wildcard is at the very end (resolved by - // finalizeState). When the wildcard was resolved - // mid-match (a subsequent literal matched), the - // last literal is the more recent part and - // lastStringPartInfo handles it below. - const wildcard = savedPendingWildcard; - if ( - wildcard !== undefined && - wildcard.valueId !== undefined - ) { - debugCompletion( - "Backward exact match: offering wildcard alternatives.", - ); - const completionProperty = getGrammarCompletionProperty( - state, - wildcard.valueId, - ); - if (completionProperty !== undefined) { - updateMaxPrefixLength(wildcard.start); - if (wildcard.start === maxPrefixLength) { - properties.push(completionProperty); - closedSet = false; - // Determine separator mode for the - // property/entity slot (same logic - // as Category 3a). - let candidateNeedsSep = false; - if ( - wildcard.start > 0 && - state.spacingMode !== "none" - ) { - candidateNeedsSep = requiresSeparator( - prefix[wildcard.start - 1], - "a", - state.spacingMode, - ); - } - separatorMode = mergeSeparatorMode( - separatorMode, - candidateNeedsSep, - state.spacingMode, - ); - } - } - } else if (state.lastStringPartInfo !== undefined) { - // No wildcard — back up to the last matched - // literal word so the user can reconsider it. - const { start, part: lastPart } = - state.lastStringPartInfo; - const backResult = tryPartialStringMatch( - lastPart, - prefix, - start, - state.spacingMode, - "backward", - ); - if (backResult !== undefined) { - debugCompletion( - `Backward exact match: offering last literal word "${backResult.remainingText}".`, - ); - updateMaxPrefixLength(backResult.consumedLength); - if (backResult.consumedLength === maxPrefixLength) { - completions.push(backResult.remainingText); - let candidateNeedsSep = false; - if ( - backResult.consumedLength > 0 && - backResult.remainingText.length > 0 && - state.spacingMode !== "none" - ) { - candidateNeedsSep = requiresSeparator( - prefix[backResult.consumedLength - 1], - backResult.remainingText[0], - state.spacingMode, - ); - } - separatorMode = mergeSeparatorMode( - separatorMode, - candidateNeedsSep, - state.spacingMode, - ); - } - } else { - updateMaxPrefixLength(state.index); - } - } else { - // No wildcard and no string parts — shouldn't - // normally happen but advance maxPrefixLength - // so shorter candidates are discarded. - updateMaxPrefixLength(state.index); - } + if ( + direction === "backward" && + state.index >= prefix.length && + emitBackwardCompletion(state, savedPendingWildcard) + ) { + // Backward emitted a completion — done with this state. } else { - // Forward (default): nothing to complete after a - // full match. Record how far we got so that - // completion candidates from shorter partial - // matches are eagerly discarded. debugCompletion("Matched. Nothing to complete."); updateMaxPrefixLength(state.index); } @@ -1493,53 +1573,15 @@ export function matchGrammarCompletion( if ( direction === "backward" && - state.lastStringPartInfo !== undefined + state.index >= prefix.length && + emitBackwardCompletion(state, savedPendingWildcard) ) { - // Backward: back up to the last matched literal word - // instead of offering the next unmatched part. - const { start, part: lastPart } = state.lastStringPartInfo; - const backResult = tryPartialStringMatch( - lastPart, - prefix, - start, - state.spacingMode, - "backward", - ); - if (backResult !== undefined) { - const candidatePrefixLength = backResult.consumedLength; - const completionText = backResult.remainingText; - updateMaxPrefixLength(candidatePrefixLength); - if (candidatePrefixLength === maxPrefixLength) { - debugCompletion( - `Backward Category 2: offering "${completionText}" (consumed ${candidatePrefixLength} chars)`, - ); - 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, - ); - } - } + // Backward emitted a completion — done with this state. } else { debugCompletion( `Completing ${nextPart.type} part ${state.name}`, ); if (nextPart.type === "string") { - // Use tryPartialStringMatch for one-word-at-a-time - // progression through string parts. const partial = tryPartialStringMatch( nextPart, prefix, @@ -1547,39 +1589,11 @@ export function matchGrammarCompletion( state.spacingMode, ); 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, - ); - } + emitStringCompletion( + state, + partial.consumedLength, + partial.remainingText, + ); } } } @@ -1607,47 +1621,11 @@ export function matchGrammarCompletion( // property completion describing the wildcard's type so // the caller can provide entity-specific suggestions. debugCompletion("Completing wildcard part"); - const completionProperty = getGrammarCompletionProperty( + emitPropertyCompletion( state, pendingWildcard.valueId, + pendingWildcard.start, ); - if (completionProperty !== undefined) { - 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) { // --- Category 3b: Completion after consumed prefix --- // The grammar stopped at a string part it could not @@ -1664,14 +1642,6 @@ export function matchGrammarCompletion( currentPart !== undefined && 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). - // - // For backward direction, offer the last matched word - // instead of the next unmatched word. const partial = tryPartialStringMatch( currentPart, prefix, @@ -1679,40 +1649,11 @@ export function matchGrammarCompletion( state.spacingMode, direction, ); - 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, + if (partial !== undefined) { + emitStringCompletion( + state, + partial.consumedLength, + partial.remainingText, ); } } @@ -1720,6 +1661,20 @@ export function matchGrammarCompletion( } } + // Advance past trailing separators so the reported prefix length + // includes any trailing whitespace the user typed. This makes + // completion trailing-space-sensitive: "play music " reports + // matchedPrefixLength=11 (with the space) rather than 10. + // + // When advancing, demote separatorMode to "optional" — the + // trailing space is already consumed, so no additional separator + // is required between the anchor and the completion text. + const advanced = consumeTrailingSeparators(prefix, maxPrefixLength); + if (advanced > maxPrefixLength) { + maxPrefixLength = advanced; + separatorMode = "optional"; + } + const result: GrammarCompletionResult = { completions, properties, diff --git a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts index 195f419d11..893a2bc9cc 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts @@ -30,7 +30,7 @@ describe("Grammar Completion - longest match property", () => { it("completes second part after first matched with space", () => { const result = matchGrammarCompletion(grammar, "first "); expect(result.completions).toContain("second"); - expect(result.matchedPrefixLength).toBe(5); + expect(result.matchedPrefixLength).toBe(6); }); it("completes third part after first two matched", () => { @@ -42,7 +42,7 @@ describe("Grammar Completion - longest match property", () => { 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); + expect(result.matchedPrefixLength).toBe(13); }); it("no completion for exact full match", () => { @@ -194,7 +194,7 @@ describe("Grammar Completion - longest match property", () => { it("completes 'percent' after number with space", () => { const result = matchGrammarCompletion(grammar, "set volume 50 "); expect(result.completions).toContain("percent"); - expect(result.matchedPrefixLength).toBe(13); + expect(result.matchedPrefixLength).toBe(14); }); it("no completion for exact match", () => { @@ -467,14 +467,15 @@ describe("Grammar Completion - longest match property", () => { expect(r1.completions).toContain("two"); }); - it("matchedPrefixLength is stable regardless of trailing spaces", () => { + it("matchedPrefixLength includes trailing whitespace", () => { 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); + // Each includes the trailing whitespace in + // matchedPrefixLength: "one"=3, "one "=4, "one "=5. + expect(r1.matchedPrefixLength).toBe(3); + expect(r2.matchedPrefixLength).toBe(4); + expect(r3.matchedPrefixLength).toBe(5); }); }); diff --git a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts index 9b94ce84ce..fa7eb7c382 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts @@ -28,9 +28,9 @@ describe("Grammar Completion - matchedPrefixLength", () => { 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. + // is consumed (4 chars), trailing space advances to 5. expect(result.completions).toEqual(["music"]); - expect(result.matchedPrefixLength).toBe(4); + expect(result.matchedPrefixLength).toBe(5); }); it("returns first word for non-matching input", () => { @@ -73,7 +73,7 @@ describe("Grammar Completion - matchedPrefixLength", () => { 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); + expect(result.matchedPrefixLength).toBe(5); }); it("returns second part for partial second word", () => { @@ -101,7 +101,7 @@ describe("Grammar Completion - matchedPrefixLength", () => { it("returns both completions after shared prefix", () => { const result = matchGrammarCompletion(grammar, "play "); expect(result.completions.sort()).toEqual(["music", "video"]); - expect(result.matchedPrefixLength).toBe(4); + expect(result.matchedPrefixLength).toBe(5); }); }); @@ -114,10 +114,10 @@ describe("Grammar Completion - matchedPrefixLength", () => { // "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. + // the terminator string. Trailing space advances to 5. const result = matchGrammarCompletion(grammar, "play "); expect(result.completions).toEqual([]); - expect(result.matchedPrefixLength).toBe(4); + expect(result.matchedPrefixLength).toBe(5); }); it("returns terminator with matchedPrefixLength tracking wildcard text", () => { @@ -147,11 +147,11 @@ describe("Grammar Completion - matchedPrefixLength", () => { 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. + // property completion instead. Trailing space advances + // matchedPrefixLength to 5. const result = matchGrammarCompletion(grammar, "play "); expect(result.completions).toHaveLength(0); - expect(result.matchedPrefixLength).toBe(4); + expect(result.matchedPrefixLength).toBe(5); }); }); @@ -179,7 +179,7 @@ describe("Grammar Completion - matchedPrefixLength", () => { it("returns noun completion after CJK verb with space", () => { const result = matchGrammarCompletion(grammar, "再生 "); expect(result.completions).toEqual(["音楽"]); - expect(result.matchedPrefixLength).toBe(2); + expect(result.matchedPrefixLength).toBe(3); }); it("returns no completions for exact match", () => { @@ -217,9 +217,10 @@ describe("Grammar Completion - matchedPrefixLength", () => { 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. + // Trailing space advances matchedPrefixLength to 3. const result = matchGrammarCompletion(grammar, "再生 "); expect(result.completions).toEqual([]); - expect(result.matchedPrefixLength).toBe(2); + expect(result.matchedPrefixLength).toBe(3); }); it("returns terminator after CJK prefix + wildcard text", () => { @@ -246,12 +247,10 @@ describe("Grammar Completion - matchedPrefixLength", () => { 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"); + // Trailing space consumed: matchedPrefixLength advances to 5, + // separatorMode demoted to "optional" (space already included). + expect(result.matchedPrefixLength).toBe(5); + expect(result.separatorMode).toBe("optional"); }); it("reports optional separatorMode for empty input", () => { @@ -346,12 +345,11 @@ describe("Grammar Completion - matchedPrefixLength", () => { }); 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". + // Trailing space consumed: matchedPrefixLength advances to 5, + // separatorMode demoted to "optional". const result = matchGrammarCompletion(grammar, "play "); expect(result.properties?.length).toBeGreaterThan(0); - expect(result.separatorMode).toBe("spacePunctuation"); + expect(result.separatorMode).toBe("optional"); }); }); @@ -550,23 +548,89 @@ describe("Grammar Completion - matchedPrefixLength", () => { }); }); + describe("wildcard is last matched part before unmatched literal", () => { + const g = [ + `entity TrackName;`, + `entity ArtistName;`, + ` = play $(track:TrackName) by $(artist:ArtistName) -> { actionName: "play", parameters: { track, artist } };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward on 'play Nocturne' backs up to wildcard $(track)", () => { + const result = matchGrammarCompletion( + grammar, + "play Nocturne", + undefined, + "backward", + ); + // "play" matched, $(track) captured "Nocturne", + // "by" is unmatched. Backward should back up to + // the wildcard — the last matched item — and offer + // property completions for $(track). + expect(result.properties?.length).toBeGreaterThan(0); + expect(result.properties![0].propertyNames).toContain( + "parameters.track", + ); + expect(result.matchedPrefixLength).toBe(4); + expect(result.closedSet).toBe(false); + }); + + it("forward on 'play Nocturne' offers 'by'", () => { + const result = matchGrammarCompletion( + grammar, + "play Nocturne", + undefined, + "forward", + ); + expect(result.completions).toEqual(["by"]); + expect(result.matchedPrefixLength).toBe(13); + }); + }); + describe("backward on partial input backs up to first word", () => { const g = ` = play music -> true;`; const grammar = loadGrammarRules("test.grammar", g); - it("backward on 'play ' backs up to 'play'", () => { + it("backward on empty input offers first word (same as forward)", () => { const result = matchGrammarCompletion( grammar, - "play ", + "", undefined, "backward", ); - // Only "play" matched. Backward backs up to offer + // Nothing matched — nothing to back up to, so + // backward falls through to forward and offers "play". + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("backward on 'play' (no trailing space) backs up to 'play'", () => { + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "backward", + ); + // No trailing space — backward backs up to offer // "play" at position 0 (reconsider the first word). expect(result.completions).toEqual(["play"]); expect(result.matchedPrefixLength).toBe(0); }); + it("trailing space commits — backward on 'play ' offers next word (same as forward)", () => { + const result = matchGrammarCompletion( + grammar, + "play ", + undefined, + "backward", + ); + // Trailing space is a commit signal — direction no + // longer matters. Should offer "music" at position 5, + // same as forward. + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(5); + }); + it("forward on 'play ' offers next word", () => { const result = matchGrammarCompletion( grammar, @@ -575,6 +639,35 @@ describe("Grammar Completion - matchedPrefixLength", () => { "forward", ); expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(5); + }); + + it("backward on partial 'pl' still offers 'play' (no complete token to reconsider)", () => { + const result = matchGrammarCompletion( + grammar, + "pl", + undefined, + "backward", + ); + // "pl" is a partial prefix of "play". No word was + // fully matched, so backward has nothing to back up + // to — falls through to forward and offers "play". + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("backward on 'play no' (partial second word) still offers 'now'", () => { + const result = matchGrammarCompletion( + grammar, + "play no", + undefined, + "backward", + ); + // "play" fully matched but "no" doesn't fully match + // "now". There is remaining unmatched input beyond + // the separator, so backward falls through to forward + // and offers "now". + expect(result.completions).toEqual(["music"]); expect(result.matchedPrefixLength).toBe(4); }); }); @@ -603,5 +696,452 @@ describe("Grammar Completion - matchedPrefixLength", () => { expect(result.closedSet).toBe(false); }); }); + + describe("varNumber part backward", () => { + const g = [ + ` = play $(count) songs -> { actionName: "play", parameters: { count } };`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward on 'play 5 songs' backs up to number slot", () => { + const result = matchGrammarCompletion( + grammar, + "play 5 songs", + undefined, + "backward", + ); + // "play" matched (string), 5 matched (number), + // "songs" matched (string). The last matched part + // is the string "songs". Backward backs up to offer + // "songs" at position 6. + expect(result.completions).toEqual(["songs"]); + expect(result.matchedPrefixLength).toBe(6); + }); + + it("backward on 'play 5' backs up to number slot (property)", () => { + const result = matchGrammarCompletion( + grammar, + "play 5", + undefined, + "backward", + ); + // "play" matched, 5 matched as the number part. + // Backward backs up to the number slot and offers + // a property completion for $(count). + expect(result.properties?.length).toBeGreaterThan(0); + expect(result.properties![0].propertyNames).toContain( + "parameters.count", + ); + expect(result.matchedPrefixLength).toBe(4); + expect(result.closedSet).toBe(false); + }); + + it("forward on 'play 5' offers 'songs'", () => { + const result = matchGrammarCompletion( + grammar, + "play 5", + undefined, + "forward", + ); + expect(result.completions).toEqual(["songs"]); + expect(result.matchedPrefixLength).toBe(6); + }); + }); + }); + + describe("trailing separator commits token across spacing modes", () => { + // Trailing separator neutralizes backward direction. + // The specific separator characters depend on the spacing mode. + + describe("default (auto) spacing", () => { + const g = ` = play music now -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("trailing space commits — backward on 'play music ' acts like forward", () => { + const result = matchGrammarCompletion( + grammar, + "play music ", + undefined, + "backward", + ); + // Trailing space commits "music"; should offer "now". + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("trailing punctuation commits — backward on 'play music,' acts like forward", () => { + const result = matchGrammarCompletion( + grammar, + "play music,", + undefined, + "backward", + ); + // Trailing comma is a separator in auto mode; commits "music". + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("no trailing separator — backward on 'play music' backs up", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "backward", + ); + // No trailing separator; backward backs up to "music". + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("spacing=required", () => { + const g = [ + ` [spacing=required] = play music now -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("trailing space commits — backward acts like forward", () => { + const result = matchGrammarCompletion( + grammar, + "play music ", + undefined, + "backward", + ); + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("no trailing separator — backward backs up", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "backward", + ); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("spacing=optional", () => { + const g = [ + ` [spacing=optional] = play music now -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("trailing space commits — backward acts like forward", () => { + const result = matchGrammarCompletion( + grammar, + "play music ", + undefined, + "backward", + ); + expect(result.completions).toEqual(["now"]); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("no trailing separator — backward backs up", () => { + const result = matchGrammarCompletion( + grammar, + "playmusic", + undefined, + "backward", + ); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("spacing=none", () => { + // In none mode, whitespace and punctuation are literal + // content, not separators — backward should always work. + const g = [` [spacing=none] = play music -> true;`].join( + "\n", + ); + const grammar = loadGrammarRules("test.grammar", g); + + it("trailing space does NOT commit — backward on 'play ' still backs up", () => { + // "play " does not match "playmusic" in none mode, + // so "play" is the only matched word. Space is not a + // separator in none mode, so backward is not neutralized. + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "backward", + ); + expect(result.completions).toEqual(["play"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("forward on 'play' offers 'music' in none mode", () => { + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(4); + }); + }); + + describe("auto spacing with CJK", () => { + const g = [` [spacing=auto] = 再生 音楽 停止 -> true;`].join( + "\n", + ); + const grammar = loadGrammarRules("test.grammar", g); + + it("trailing space commits — backward on CJK '再生 音楽 ' acts like forward", () => { + const result = matchGrammarCompletion( + grammar, + "再生 音楽 ", + undefined, + "backward", + ); + // Trailing space commits; should offer "停止". + expect(result.completions).toEqual(["停止"]); + }); + + it("no trailing separator — backward on CJK '再生音楽' backs up", () => { + const result = matchGrammarCompletion( + grammar, + "再生音楽", + undefined, + "backward", + ); + // CJK auto mode: no separator between chars, so no + // trailing separator. Backward backs up to "音楽". + expect(result.completions).toEqual(["音楽"]); + expect(result.matchedPrefixLength).toBe(2); + }); + }); + }); + + describe("escaped space in last term", () => { + // An escaped space (\ ) makes the space part of the literal + // token content. The trailing separator check should NOT + // treat such a space as a commit signal. + + describe("default (auto) spacing — single segment with literal space", () => { + // "hello\ world" parses as one segment: "hello world" + // The rule has two tokens: ["hello world", "next"] + const g = ` = hello\\ world next -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("forward on 'hello world' offers 'next'", () => { + const result = matchGrammarCompletion( + grammar, + "hello world", + undefined, + "forward", + ); + expect(result.completions).toEqual(["next"]); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("backward on 'hello world' (no trailing separator) backs up to 'hello world'", () => { + const result = matchGrammarCompletion( + grammar, + "hello world", + undefined, + "backward", + ); + // "hello world" is one token — no trailing separator + // after it, so backward should back up. + expect(result.completions).toEqual(["hello world"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("forward on partial 'hello ' (mid-token literal space) still matches", () => { + const result = matchGrammarCompletion( + grammar, + "hello ", + undefined, + "forward", + ); + // "hello " is a partial match of the "hello world" + // token — forward should offer "hello world". + expect(result.completions).toEqual(["hello world"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("backward on partial 'hello ' (mid-token literal space) falls through to forward", () => { + const result = matchGrammarCompletion( + grammar, + "hello ", + undefined, + "backward", + ); + // "hello " is a partial match of the single segment + // "hello world". No complete segment was matched, + // so backward falls through to forward and offers + // "hello world". + expect(result.completions).toEqual(["hello world"]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("trailing separator after complete token — backward on 'hello world ' acts like forward", () => { + const result = matchGrammarCompletion( + grammar, + "hello world ", + undefined, + "backward", + ); + // The space after "hello world" IS a real separator. + // Should commit and offer "next". + expect(result.completions).toEqual(["next"]); + expect(result.matchedPrefixLength).toBe(12); + }); + }); + + describe("spacing=none — literal space is never a separator", () => { + const g = ` [spacing=none] = hello\\ world next -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("forward on 'hello world' offers 'next' (tokens directly adjacent)", () => { + const result = matchGrammarCompletion( + grammar, + "hello world", + undefined, + "forward", + ); + // In none mode, "hello world" and "next" are adjacent: + // matched input would be "hello worldnext". + expect(result.completions).toEqual(["next"]); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("backward on 'hello world' backs up (space is literal, not separator)", () => { + const result = matchGrammarCompletion( + grammar, + "hello world", + undefined, + "backward", + ); + expect(result.completions).toEqual(["hello world"]); + expect(result.matchedPrefixLength).toBe(0); + }); + }); + }); + + describe("literal space followed by flex-space", () => { + // Grammar source: `hello\ world` — escaped space then + // unescaped whitespace. Parser produces segments + // ["hello ", "world"]: first segment ends with literal + // space, then a flex-space boundary before "world". + // + // The regex merges the literal and flex-space: the pattern + // is approximately `hello\ [\s\p{P}]*world`. When input is + // "hello " the regex matches "hello " as segment 1 completely + // and passes through the zero-width flex-space — so the + // matcher treats this as a segment boundary, not a mid-token + // partial. + + describe("default (auto) spacing", () => { + const g = ` = hello\\ world next -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("forward on 'hello ' — first segment fully matched, offers second segment", () => { + const result = matchGrammarCompletion( + grammar, + "hello ", + undefined, + "forward", + ); + // "hello " matches segment 1 exactly; flex-space + // consumed zero chars. Offers "world". + expect(result.completions).toEqual(["world"]); + expect(result.matchedPrefixLength).toBe(6); + }); + + it("backward on 'hello ' — literal space consumed, backs up to 'hello '", () => { + const result = matchGrammarCompletion( + grammar, + "hello ", + undefined, + "backward", + ); + // The literal space IS part of the first segment. + // The regex consumed it as token content, so there is + // no trailing separator beyond the match — backward + // backs up to offer "hello " at position 0. + expect(result.completions).toEqual(["hello "]); + expect(result.matchedPrefixLength).toBe(0); + }); + + it("forward on 'hello ' — literal space + flex-space offers 'world'", () => { + const result = matchGrammarCompletion( + grammar, + "hello ", + undefined, + "forward", + ); + // "hello " (literal) + " " (flex-space) = 7 chars consumed. + expect(result.completions).toEqual(["world"]); + expect(result.matchedPrefixLength).toBe(7); + }); + + it("backward on 'hello ' — real trailing separator commits", () => { + const result = matchGrammarCompletion( + grammar, + "hello ", + undefined, + "backward", + ); + // "hello " (literal) consumed 6 chars. The extra + // space at position 6 is a real separator beyond the + // match — commits the segment, acts like forward. + expect(result.completions).toEqual(["world"]); + expect(result.matchedPrefixLength).toBe(7); + }); + + it("forward on 'hello world' offers 'next'", () => { + const result = matchGrammarCompletion( + grammar, + "hello world", + undefined, + "forward", + ); + expect(result.completions).toEqual(["next"]); + expect(result.matchedPrefixLength).toBe(11); + }); + + it("backward on 'hello world' backs up to 'world' at segment boundary", () => { + const result = matchGrammarCompletion( + grammar, + "hello world", + undefined, + "backward", + ); + // Full token matched. Backward backs up to + // "world" at position 6 (after "hello "). + expect(result.completions).toEqual(["world"]); + expect(result.matchedPrefixLength).toBe(6); + }); + + it("forward on 'hello world ' offers 'next'", () => { + const result = matchGrammarCompletion( + grammar, + "hello world ", + undefined, + "forward", + ); + expect(result.completions).toEqual(["next"]); + expect(result.matchedPrefixLength).toBe(12); + }); + + it("backward on 'hello world ' — trailing separator commits", () => { + const result = matchGrammarCompletion( + grammar, + "hello world ", + undefined, + "backward", + ); + // Trailing space after complete token commits. + expect(result.completions).toEqual(["next"]); + expect(result.matchedPrefixLength).toBe(12); + }); + }); }); }); diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index ecd804bf5f..75ae67fce1 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -383,7 +383,12 @@ export class ConstructionCache { const namespaceKeys = options?.namespaceKeys; debugCompletion(`Request completion namespace keys`, namespaceKeys); - const backward = direction === "backward"; + // Trailing separator (whitespace or punctuation) is a commit + // signal: the token before it is committed and direction no + // longer matters. Neutralize backward so the matcher doesn't + // back up. + const backward = + direction === "backward" && !/[\s\p{P}]$/u.test(requestPrefix); const results = this.match(requestPrefix, options, true, backward); debugCompletion( @@ -545,6 +550,18 @@ export class ConstructionCache { } } + // Advance past trailing separators so that the reported prefix + // length includes any trailing whitespace the user typed. + // When advancing, demote separatorMode to "optional" — the + // trailing space is already consumed. + if (maxPrefixLength < requestPrefix.length) { + const trailing = requestPrefix.substring(maxPrefixLength); + if (/^[\s\p{P}]+$/u.test(trailing)) { + maxPrefixLength = requestPrefix.length; + separatorMode = "optional"; + } + } + return { completions: requestText, properties: completionProperty, diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index bcf33bec4f..2c5a189c5f 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -106,8 +106,8 @@ describe("ConstructionCache.completion()", () => { 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); + // is consumed as a trailing separator → matchedPrefixLength=5. + expect(result!.matchedPrefixLength).toBe(5); }); it("returns matchedPrefixLength for partial single-part match", () => { @@ -207,10 +207,9 @@ describe("ConstructionCache.completion()", () => { 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"); + // The matcher consumes "play" (4 chars). Trailing space + // is consumed, so separatorMode is demoted to "optional". + expect(result!.separatorMode).toBe("optional"); }); it("returns spacePunctuation between adjacent word characters", () => { @@ -451,14 +450,14 @@ describe("ConstructionCache.completion()", () => { expect(result!.closedSet).toBe(true); }); - it("prefix 'play ' — trailing space ignored, still offers second part", () => { + it("prefix 'play ' — trailing space consumed, 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"); + // Trailing space consumed → matchedPrefixLength advances to 5, + // separatorMode demoted to "optional". + expect(result!.matchedPrefixLength).toBe(5); + expect(result!.separatorMode).toBe("optional"); }); it("prefix 'play s' — partial intra-part on second part, returns completions", () => { @@ -935,7 +934,7 @@ describe("ConstructionCache.completion()", () => { }); describe("partial match backs up to previous part", () => { - it("backward on 'play ' backs up to 'play'", () => { + it("backward on 'play' (no trailing space) backs up to 'play'", () => { const c = Construction.create( [ createMatchPart(["play"], "verb"), @@ -945,17 +944,38 @@ describe("ConstructionCache.completion()", () => { ); const cache = makeCache([c]); const result = cache.completion( - "play ", + "play", defaultOptions, "backward", ); expect(result).toBeDefined(); - // "play" was the last matched part. Backward backs - // up to offer "play" at position 0. + // No trailing space — backward backs up to offer + // "play" at position 0. expect(result!.completions).toContain("play"); expect(result!.matchedPrefixLength).toBe(0); }); + it("trailing space commits — backward on 'play ' offers next part (same as forward)", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play ", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + // Trailing space is a commit signal — direction no + // longer matters. Should offer "song" same as forward. + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(5); + }); + it("forward on 'play ' offers next part", () => { const c = Construction.create( [ @@ -972,7 +992,7 @@ describe("ConstructionCache.completion()", () => { ); expect(result).toBeDefined(); expect(result!.completions).toContain("song"); - expect(result!.matchedPrefixLength).toBe(4); + expect(result!.matchedPrefixLength).toBe(5); }); }); @@ -1043,5 +1063,90 @@ describe("ConstructionCache.completion()", () => { expect(result!.matchedPrefixLength).toBe(4); }); }); + + describe("trailing separator commits token", () => { + // The cache uses /[\s\p{P}]$/u to detect trailing + // separators — both whitespace and punctuation commit. + + it("trailing punctuation commits — backward on 'play,' offers next part", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play,", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + // Trailing comma is a separator — commits "play". + // Should offer "song" same as forward. + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(5); + }); + + it("trailing period commits — backward on 'play.' offers next part", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play.", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toContain("song"); + expect(result!.matchedPrefixLength).toBe(5); + }); + + it("no trailing separator — backward on 'play' backs up", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + expect(result!.completions).toContain("play"); + expect(result!.matchedPrefixLength).toBe(0); + }); + + it("mid-input trailing separator — backward on 'play song,' offers next part", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + createMatchPart(["now"], "adv"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song,", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + // Trailing comma after second word commits "song". + expect(result!.completions).toContain("now"); + expect(result!.matchedPrefixLength).toBe(10); + }); + }); }); }); diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index e9fc9e056b..22277c1a12 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -74,27 +74,13 @@ function detectPendingFlag( }; } -// 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. -// -// Every production site for startIndex — resolveCommand consumed -// length, parseParams remainder, and the lastCompletableParam -// adjustment — calls this function so that startIndex always lands -// on a token boundary, never on separator whitespace. Consumers -// treat input[startIndex..] as a "rawPrefix" that starts with a -// separator (per separatorMode, defaulting to "space") and strip -// the leading separator before trie filtering. -// -// The grammar-reported matchedPrefixLength override is added to -// tokenStartIndex (before the separator space), not to the result -// of this function — the grammar reports how many characters of the -// token *content* it consumed, which is relative to the token start. -function tokenBoundary(text: string, index: number): number { - while (index > 0 && /\s/.test(text[index - 1])) { - index--; - } - return index; +// True when text[0..index) ends with whitespace — i.e., the user +// has typed a trailing space after the last token. This serves as +// a commit signal: the token before the space is committed and the +// space itself is consumed, so startIndex should include it and +// separatorMode should be "optional" (no additional separator needed). +function hasTrailingSpace(text: string, index: number): boolean { + return index > 0 && /\s/.test(text[index - 1]); } // True if surrounded by quotes at both ends (matching single or double quotes). @@ -188,11 +174,8 @@ type ParameterCompletionResult = CommandCompletionResult; // 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). +// override). Also used by the caller to apply +// matchedPrefixLength arithmetic. // 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 @@ -203,13 +186,17 @@ type ParameterCompletionResult = CommandCompletionResult; // completions via collectFlags. // booleanFlagName — when non-undefined, the caller should add // ["true","false"] completions for this flag. +// separatorMode — when set, the caller should use this as the +// base separator mode (before merging with +// agent-reported modes). "optional" when +// trailing whitespace was consumed by startIndex. type CompletionTarget = { completionNames: string[]; startIndex: number; - tokenStartIndex: number; isPartialValue: boolean; includeFlags: boolean; booleanFlagName: string | undefined; + separatorMode: SeparatorMode | undefined; }; function resolveCompletionTarget( @@ -227,14 +214,15 @@ function resolveCompletionTarget( // 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, + startIndex: remainderIndex, isPartialValue: false, includeFlags: true, booleanFlagName, + separatorMode: hasTrailingSpace(input, remainderIndex) + ? "optional" + : undefined, }; } @@ -257,15 +245,16 @@ function resolveCompletionTarget( pendingFlag, ) ) { - const tokenStartIndex = remainderIndex - valueToken.length; - const startIndex = tokenBoundary(input, tokenStartIndex); + const startIndex = remainderIndex - valueToken.length; return { completionNames: [lastCompletableParam], startIndex, - tokenStartIndex, isPartialValue: true, includeFlags: false, booleanFlagName: undefined, + separatorMode: hasTrailingSpace(input, startIndex) + ? "optional" + : undefined, }; } } @@ -276,40 +265,54 @@ function resolveCompletionTarget( // "--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 && direction === "backward") { + // + // Trailing space commits the flag — direction no longer matters. + // When the user typed "--level " (with space), they've moved on; + // fall through to 3b for value completions regardless of direction. + const trailingSpace = hasTrailingSpace(input, remainderIndex); + if ( + pendingFlag !== undefined && + direction === "backward" && + !trailingSpace + ) { const flagToken = tokens[tokens.length - 1]; const flagTokenStart = remainderIndex - flagToken.length; - const startIndex = tokenBoundary(input, flagTokenStart); return { completionNames: [], - startIndex, - tokenStartIndex: startIndex, + startIndex: flagTokenStart, isPartialValue: false, includeFlags: true, booleanFlagName, + separatorMode: hasTrailingSpace(input, flagTokenStart) + ? "optional" + : undefined, }; } // ── Spec case 3b: last token committed, complete next ─────── - const startIndex = tokenBoundary(input, remainderIndex); - if (pendingFlag !== undefined && direction === "forward") { - // Flag awaiting a value and the user moved forward. + // startIndex is the raw position — includes any trailing + // whitespace that the user typed. When trailing whitespace is + // present, separatorMode becomes "optional" because the space + // is already consumed. + if (pendingFlag !== undefined) { + // Flag awaiting a value — either the user moved forward or + // trailing space committed the flag (direction doesn't matter). return { completionNames: [pendingFlag], - startIndex, - tokenStartIndex: startIndex, + startIndex: remainderIndex, isPartialValue: false, includeFlags: false, booleanFlagName: undefined, + separatorMode: trailingSpace ? "optional" : undefined, }; } return { completionNames: [...params.nextArgs], - startIndex, - tokenStartIndex: startIndex, + startIndex: remainderIndex, isPartialValue: false, includeFlags: true, booleanFlagName, + separatorMode: trailingSpace ? "optional" : undefined, }; } @@ -344,8 +347,10 @@ function resolveCompletionTarget( // // b. Otherwise — the last token is complete (direction="forward", // fully quoted, or trailing whitespace). Return startIndex -// at the *end* of the last token (excluding trailing space) -// and offer completions for the next parameters. +// at the *end* of the consumed input (including any trailing +// space) and offer completions for the next parameters. When +// trailing whitespace is present, separatorMode is "optional" +// because the space is already consumed. // // ── Exceptions to case 3a ──────────────────────────────────────────────── // @@ -425,7 +430,7 @@ async function getCommandParameterCompletion( // full list of names to complete. let agentInvoked = false; let agentClosedSet: boolean | undefined; - let separatorMode: SeparatorMode | undefined; + let separatorMode: SeparatorMode | undefined = target.separatorMode; const agent = context.agents.getAppAgent(result.actualAppAgentName); if (agent.getCommandCompletion && target.completionNames.length > 0) { @@ -444,31 +449,24 @@ async function getCommandParameterCompletion( // 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 + // 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. - // - // IMPORTANT: Do NOT apply tokenBoundary() or any whitespace - // normalization to groupPrefixLength. The agent owns the - // boundary. Grammars with escaped literal spaces (e.g. - // `hello\ ` where the space is part of the token) will - // include that whitespace in matchedPrefixLength, yielding - // a startIndex that already sits past a separator. When - // paired with separatorMode="spacePunctuation", this means - // the grammar requires a *second* separator — the shell's - // A3 separator check handles this correctly. + // content start, so add it to target.startIndex. const groupPrefixLength = agentResult.matchedPrefixLength; if (groupPrefixLength !== undefined && groupPrefixLength !== 0) { - startIndex = target.tokenStartIndex + groupPrefixLength; + startIndex = target.startIndex + groupPrefixLength; completions.length = 0; // grammar overrides built-in completions + // The agent advanced the prefix — it is authoritative for + // the separator at this position. + separatorMode = agentResult.separatorMode; } completions.push(...agentResult.groups); - separatorMode = agentResult.separatorMode; agentInvoked = true; agentClosedSet = agentResult.closedSet; debug( - `Command completion parameter with agent: groupPrefixLength=${groupPrefixLength}, startIndex=${startIndex}, tokenStartIndex=${target.tokenStartIndex}`, + `Command completion parameter with agent: groupPrefixLength=${groupPrefixLength}, startIndex=${startIndex}`, ); } @@ -625,7 +623,7 @@ export async function getCommandCompletion( context: CommandHandlerContext, ): Promise { try { - debug(`Command completion start: '${input}'`); + debug(`Command completion start ${direction}: '${input}'`); // Always send the full input so the backend sees all typed text. const partialCommand = normalizeCommand(input, context); @@ -642,11 +640,20 @@ export async function getCommandCompletion( debug( `Command completion command consumed length: ${commandConsumedLength}, suffix: '${result.suffix}'`, ); - let startIndex = tokenBoundary(input, commandConsumedLength); + let startIndex = commandConsumedLength; // Collect completions and track separatorMode across all sources. + // When trailing whitespace was consumed *and* nothing follows + // (suffix is empty), separatorMode starts at "optional" — the + // space is already part of the anchor so no additional separator + // is needed. When a suffix exists (e.g. "--off"), the space + // before it is structural, not trailing. const completions: CompletionGroup[] = []; - let separatorMode: SeparatorMode | undefined; + let separatorMode: SeparatorMode | undefined = + result.suffix.length === 0 && + hasTrailingSpace(input, commandConsumedLength) + ? "optional" + : undefined; let closedSet = true; const descriptor = result.descriptor; @@ -674,8 +681,7 @@ export async function getCommandCompletion( if (uncommittedCommand) { const lastCmd = result.commands[result.commands.length - 1]; - startIndex = - tokenBoundary(input, commandConsumedLength) - lastCmd.length; + startIndex = commandConsumedLength - lastCmd.length; completions.push({ name: "Subcommands", completions: Object.keys(table!.commands), diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index 916f961125..594c6b7743 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -503,9 +503,8 @@ describe("Command Completion - startIndex", () => { // "@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); + // startIndex = inputLength - 0 = 14 (includes trailing space). + expect(result!.startIndex).toBe(14); // Agent getCompletion is invoked for the "task" arg → // completions are not exhaustive. expect(result!.closedSet).toBe(false); @@ -523,8 +522,8 @@ describe("Command Completion - startIndex", () => { // suffix is "bu", parameter parsing fully consumes "bu". // lastCompletableParam="task", bare unquoted token, // no trailing space → exclusive path fires: backs up - // startIndex to the start of "bu" → 13. - expect(result!.startIndex).toBe(13); + // startIndex to the start of "bu" → 14. + expect(result!.startIndex).toBe(14); // Agent IS invoked ("task" in agentCommandCompletions). // Agent does not set closedSet → defaults to false. expect(result!.closedSet).toBe(false); @@ -539,9 +538,9 @@ describe("Command Completion - startIndex", () => { 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); + // parameter parsing has no tokens; startIndex = 21 - 0 = 21 + // (includes trailing space). + expect(result!.startIndex).toBe(21); // Unfilled "value" arg (free-form) → not exhaustive. expect(result!.closedSet).toBe(false); }); @@ -555,9 +554,8 @@ describe("Command Completion - startIndex", () => { 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); + // startIndex = 26 - 5 = 21. + expect(result!.startIndex).toBe(21); // Unfilled "value" arg → not exhaustive. expect(result!.closedSet).toBe(false); }); @@ -589,9 +587,9 @@ describe("Command Completion - startIndex", () => { const result = await getCommandCompletion(" ", "forward", 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); + // startIndex = input.length - suffix.length = 2. + // Trailing whitespace is preserved (no tokenBoundary rewind). + expect(result!.startIndex).toBe(2); }); }); @@ -673,10 +671,10 @@ describe("Command Completion - startIndex", () => { ); // "@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. + // remainderLength = 0 → startIndex = 20 (includes + // trailing space, no rewind). expect(result).toBeDefined(); - expect(result!.startIndex).toBe(19); + expect(result!.startIndex).toBe(20); // All positional args filled ("task" consumed "build"), // no flags, agent not invoked (agentCommandCompletions // is empty) → exhaustive. @@ -693,9 +691,9 @@ describe("Command Completion - startIndex", () => { // "@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); + // startIndex = 29 - 9 = 20 (includes trailing space + // between "hello" and "--unknown"). + expect(result!.startIndex).toBe(20); }); it("startIndex backs over multiple spaces before unconsumed remainder", async () => { @@ -708,9 +706,8 @@ describe("Command Completion - startIndex", () => { // "@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); + // startIndex = 31 - 9 = 22 (includes trailing spaces). + expect(result!.startIndex).toBe(22); }); }); @@ -724,11 +721,10 @@ describe("Command Completion - startIndex", () => { expect(result).toBeDefined(); // "run" is the default subcommand, so subcommand alternatives // are included and the group has separatorMode: "space". + // Subcommand completions at the boundary retain "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); + // startIndex includes trailing whitespace. + expect(result!.startIndex).toBe(10); }); it("returns separatorMode for resolved agent without trailing space", async () => { @@ -771,8 +767,9 @@ describe("Command Completion - startIndex", () => { ); expect(result).toBeDefined(); // Partial parameter token — only parameter completions returned, - // no subcommand group, so separatorMode is not set. - expect(result!.separatorMode).toBeUndefined(); + // no subcommand group. separatorMode set to "optional" + // due to trailing space advancement. + expect(result!.separatorMode).toBe("optional"); }); it("returns no separatorMode for partial unmatched token consumed as param", async () => { @@ -783,16 +780,16 @@ describe("Command Completion - startIndex", () => { ); 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". + // parsing. No trailing space. startIndex = 10 + // (after "@comptest "), which is ≤ commandConsumedLength + // (10), so 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); + expect(result!.startIndex).toBe(10); }); }); @@ -931,9 +928,9 @@ describe("Command Completion - startIndex", () => { ); // "--level" is a recognized number flag. With // direction="backward" (user reconsidering), offer flag - // names at tokenBoundary before "--level" (position 19, - // end of "flagsonly") instead of flag values. - expect(result.startIndex).toBe(19); + // names at the start of "--level" (position 20, + // after space) instead of flag values. + expect(result.startIndex).toBe(20); const flags = result.completions.find( (g) => g.name === "Command Flags", ); @@ -951,9 +948,9 @@ describe("Command Completion - startIndex", () => { // "--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); + // "@comptest flagsonly " = 20 chars consumed, remainderLength=5, + // startIndex = 25 - 5 = 20. + expect(result.startIndex).toBe(20); const flags = result.completions.find( (g) => g.name === "Command Flags", ); @@ -989,9 +986,8 @@ describe("Command Completion - startIndex", () => { context, ); // "@flattest --rel" (15 chars) - // startIndex = 15 - 5 ("--rel") = 10, then unconditional - // whitespace backing rewinds over space → 9. - expect(result.startIndex).toBe(9); + // startIndex = 15 - 5 ("--rel") = 10 (after space). + expect(result.startIndex).toBe(10); expect(result.closedSet).toBe(false); }); @@ -1029,15 +1025,15 @@ describe("Command Completion - startIndex", () => { // "@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). + // remainderLength = 0 → startIndex = 16 (includes + // trailing space). // 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); + expect(result.startIndex).toBe(16); // All positional args filled, no flags → exhaustive. expect(result.closedSet).toBe(true); }); @@ -1088,9 +1084,9 @@ describe("Command Completion - startIndex", () => { // 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); + // Exclusive path: tokenStartIndex = 17 - 3 = 14. + // startIndex = 14 (no rewind). + expect(result.startIndex).toBe(14); // Agent was invoked → not exhaustive. expect(result.closedSet).toBe(false); // Flag groups and nextArgs completions should be cleared. @@ -1110,10 +1106,10 @@ describe("Command Completion - startIndex", () => { // 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. + // Exclusive path: tokenStartIndex = 25 - 8 = 17. + // startIndex = 17 (no rewind). // "second" from nextArgs should NOT be in agentCommandCompletions. - expect(result.startIndex).toBe(16); + expect(result.startIndex).toBe(17); expect(result.closedSet).toBe(false); }); @@ -1127,9 +1123,9 @@ describe("Command Completion - startIndex", () => { // 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); + // Exclusive path: tokenStartIndex = 28 - 11 = 17. + // startIndex = 17 (no rewind). + expect(result.startIndex).toBe(17); expect(result.closedSet).toBe(false); }); @@ -1156,9 +1152,9 @@ describe("Command Completion - startIndex", () => { ); // "bu" is not quoted → isFullyQuoted returns undefined. // No trailing space → lastCompletableParam exclusive path - // fires: backs up startIndex to the start of "bu" → 13. + // fires: backs up startIndex to the start of "bu" → 14. // Agent IS invoked for "task" completions. - expect(result.startIndex).toBe(13); + expect(result.startIndex).toBe(14); expect(result.closedSet).toBe(false); }); }); @@ -1270,8 +1266,8 @@ describe("Command Completion - startIndex", () => { // 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); + // startIndex = 18 (includes trailing space). + expect(result.startIndex).toBe(18); expect(result.closedSet).toBe(false); const grammar = result.completions.find( (g) => g.name === "Grammar", @@ -1303,7 +1299,7 @@ describe("Command Completion - startIndex", () => { "forward", context, ); - expect(result.startIndex).toBe(13); + expect(result.startIndex).toBe(14); }); it("English prefix with space separator", async () => { @@ -1363,8 +1359,8 @@ describe("Command Completion - startIndex", () => { // branch → completions: ["Tokyo ", "東京"], // matchedPrefixLength: 0, separatorMode: "space". // groupPrefixLength = 0 → condition false → skip. - // startIndex = tokenBoundary(input, 20) = 19. - expect(result.startIndex).toBe(19); + // startIndex = 20 (includes trailing space). + expect(result.startIndex).toBe(20); const grammar = result.completions.find( (g) => g.name === "Grammar", ); @@ -1375,13 +1371,11 @@ describe("Command Completion - startIndex", () => { }); }); - describe("Bug 1: fallback startIndex uses tokenBoundary", () => { + describe("Bug 1: fallback startIndex", () => { // 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 () => { + // path handles no-trailing-space. startIndex lands at the + // raw token start position. + it("startIndex for '@comptest nested sub val' (no agent getCommandCompletion)", async () => { const result = await getCommandCompletion( "@comptest nested sub val", "forward", @@ -1392,14 +1386,13 @@ describe("Command Completion - startIndex", () => { // 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 + // is skipped. The fallback fires because // no trailing space, 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 should land at 21 (raw token start of "val"). + expect(result.startIndex).toBe(21); }); - it("startIndex at tokenBoundary for '@comptest nested sub --verbose val' (no agent getCommandCompletion)", async () => { + it("startIndex for '@comptest nested sub --verbose val' (no agent getCommandCompletion)", async () => { const result = await getCommandCompletion( "@comptest nested sub --verbose val", "forward", @@ -1411,9 +1404,8 @@ describe("Command Completion - startIndex", () => { // 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); + // startIndex at raw token start of "val" → 31. + expect(result.startIndex).toBe(31); }); }); @@ -1454,11 +1446,10 @@ describe("Command Completion - startIndex", () => { context, ); // "@numstrtest numstr 42 " (22 chars) - // Trailing space → direction="forward", fallback never - // fires. startIndex = tokenBoundary(input, 22) = 21 - // (rewinds over trailing space to end of "42"). + // Trailing space → startIndex = 22 (includes trailing + // space). // Agent invoked for "name" completions. - expect(result.startIndex).toBe(21); + expect(result.startIndex).toBe(22); const names = result.completions.find((g) => g.name === "Names"); expect(names).toBeDefined(); expect(names!.completions).toContain("alice"); @@ -1476,9 +1467,9 @@ describe("Command Completion - startIndex", () => { // suffix = "42 al", parseParams: 42 → count, "al" → name. // lastCompletableParam = "name" (string), no trailing space. // Exclusive path fires (bare token, no trailing space): - // backs up to before "al" → tokenBoundary(input, 22) = 21. + // tokenStartIndex = before "al" → 22. // Agent invoked for "name". - expect(result.startIndex).toBe(21); + expect(result.startIndex).toBe(22); const names = result.completions.find((g) => g.name === "Names"); expect(names).toBeDefined(); expect(names!.completions).toContain("alice"); @@ -1519,9 +1510,9 @@ describe("Command Completion - startIndex", () => { "backward", context, ); - // startIndex should be at the parameter boundary (13, - // end of "run"), not backed up to subcommand level. - expect(result.startIndex).toBe(13); + // startIndex should be at the parameter boundary (14, + // after trailing space), not backed up to subcommand level. + expect(result.startIndex).toBe(14); const subcommands = result.completions.find( (g) => g.name === "Subcommands", ); @@ -1573,6 +1564,24 @@ describe("Command Completion - startIndex", () => { expect(flags!.completions).toContain("--level"); }); + it("trailing space commits flag — '@comptest flagsonly --level ' backward offers value completions", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --level ", + "backward", + context, + ); + // Trailing space is a commit signal. Even though + // direction is "backward", the flag is committed and + // value completions should be offered (same as forward). + // Should NOT back up to flag alternatives. + const flags = result.completions.find( + (g) => g.name === "Command Flags", + ); + expect(flags).toBeUndefined(); + // startIndex includes the trailing space. + expect(result.startIndex).toBe(28); + }); + it("forward on '@comptest run' offers parameter completions", async () => { // Contrast with the backward test above: forward on a // resolved subcommand without trailing space should still From 3bc90c00566dfadac5af30deaf9e317adcde6f64 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 17 Mar 2026 18:59:04 -0700 Subject: [PATCH 3/7] Add directionSensitive flag to completion pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thread a directionSensitive boolean through the entire completion pipeline so the shell can avoid wasteful re-fetches when the user switches direction (forward ↔ backward) and the completions would be identical. Grammar matcher: compute directionSensitive from canBackward (matched parts exist without trailing separator) and couldBackUp (tryPartialStringMatch). Required field on GrammarCompletionResult. Construction cache: track noTrailingSeparator × partialPartCount ≥ 1 to determine when forward and backward would pick different parts. grammarStore: OR-merge directionSensitive across grammar entries. Dispatcher: set directionSensitive on CompletionTarget (5 return paths), merge with agent result via OR, propagate through completeDescriptor and getCommandCompletion. Required field on CommandCompletionResult. Shell (PartialCompletionSession): store directionSensitive and lastDirection from backend result. Re-fetch trigger A7: when direction changed AND directionSensitive AND input === anchor (at the direction-sensitive boundary). Once the user types past the anchor, the loaded completions are reused regardless of direction change. Tests: 22 grammar, 8 construction cache, 7 dispatcher, 5 shell direction tests added (all passing). --- .../actionGrammar/src/grammarMatcher.ts | 56 +++- .../actionGrammar/src/nfaCompletion.ts | 3 +- ...rammarCompletionDirectionSensitive.spec.ts | 278 ++++++++++++++++++ ts/packages/agentSdk/src/command.ts | 5 + ts/packages/cache/src/cache/grammarStore.ts | 7 + .../src/constructions/constructionCache.ts | 28 ++ ts/packages/cache/test/completion.spec.ts | 124 ++++++++ .../dispatcher/src/command/completion.ts | 35 ++- .../src/translation/requestCompletion.ts | 3 + .../dispatcher/test/completion.spec.ts | 72 +++++ .../dispatcher/types/src/dispatcher.ts | 4 + .../renderer/src/partialCompletionSession.ts | 39 ++- .../test/partialCompletion/direction.spec.ts | 99 +++++++ .../shell/test/partialCompletion/helpers.ts | 2 + .../resultProcessing.spec.ts | 7 + .../startIndexSeparatorContract.spec.ts | 1 + 16 files changed, 747 insertions(+), 16 deletions(-) create mode 100644 ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 593c1e257f..ba8491a777 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -1169,6 +1169,10 @@ export type GrammarCompletionResult = { // past unrecognized input and find more completions (e.g. // wildcard/entity slots whose values are external to the grammar). closedSet?: boolean | undefined; + // True when the result would differ if queried with the opposite + // direction. When false, the caller can skip re-fetching on + // direction change. + directionSensitive: boolean; }; function getGrammarCompletionProperty( @@ -1247,7 +1251,13 @@ function tryPartialStringMatch( startIndex: number, spacingMode: CompiledSpacingMode, direction?: "forward" | "backward", -): { consumedLength: number; remainingText: string } | undefined { +): + | { + consumedLength: number; + remainingText: string; + directionSensitive: boolean; + } + | undefined { const words = part.value; let index = startIndex; let matchedWords = 0; @@ -1274,20 +1284,18 @@ function tryPartialStringMatch( matchedWords++; } - if ( - direction === "backward" && + // Direction matters when at least one word fully matched and no + // trailing separator commits the last matched word. + const couldBackUp = matchedWords > 0 && - // Back up only when no trailing separator commits the - // last matched word. In "none" mode there are no - // separators. Otherwise, if separator characters exist - // beyond the match (nextNonSeparatorIndex > index), the - // separator commits the word — fall through to forward. (spacingMode === "none" || - nextNonSeparatorIndex(prefix, index) === index) - ) { + nextNonSeparatorIndex(prefix, index) === index); + + if (direction === "backward" && couldBackUp) { return { consumedLength: prevIndex, remainingText: words[matchedWords - 1], + directionSensitive: true, }; } // Forward (default), or backward with no words fully matched @@ -1300,6 +1308,7 @@ function tryPartialStringMatch( return { consumedLength: index, remainingText: words[matchedWords], + directionSensitive: couldBackUp, }; } @@ -1394,6 +1403,10 @@ export function matchGrammarCompletion( // whitespace (which breaks for CJK and other non-space scripts). let maxPrefixLength = minPrefixLength ?? 0; + // Whether direction influenced the accumulated results. Reset + // whenever maxPrefixLength advances (old candidates discarded). + let directionSensitive = false; + // Helper: update maxPrefixLength. When it increases, all previously // accumulated completions from shorter matches are irrelevant // — clear them. @@ -1404,6 +1417,7 @@ export function matchGrammarCompletion( properties.length = 0; separatorMode = undefined; closedSet = true; + directionSensitive = false; } } @@ -1549,12 +1563,20 @@ export function matchGrammarCompletion( // It returns true when the state is "clean" — all input was // consumed (or only trailing separators remain). if (finalizeState(state, prefix)) { + // Would backward produce different results than forward? + // True when the prefix was fully consumed and there is a + // matched part (string/number) or wildcard to back up to. + const canBackward = + state.index >= prefix.length && + (savedPendingWildcard?.valueId !== undefined || + state.lastMatchedPartInfo !== undefined); + // --- Category 1: Exact match --- // All parts matched AND prefix was fully consumed. if (matched) { if ( direction === "backward" && - state.index >= prefix.length && + canBackward && emitBackwardCompletion(state, savedPendingWildcard) ) { // Backward emitted a completion — done with this state. @@ -1562,6 +1584,9 @@ export function matchGrammarCompletion( debugCompletion("Matched. Nothing to complete."); updateMaxPrefixLength(state.index); } + if (canBackward) { + directionSensitive = true; + } continue; } @@ -1573,7 +1598,7 @@ export function matchGrammarCompletion( if ( direction === "backward" && - state.index >= prefix.length && + canBackward && emitBackwardCompletion(state, savedPendingWildcard) ) { // Backward emitted a completion — done with this state. @@ -1597,6 +1622,9 @@ export function matchGrammarCompletion( } } } + if (canBackward) { + directionSensitive = true; + } // 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 @@ -1655,6 +1683,9 @@ export function matchGrammarCompletion( partial.consumedLength, partial.remainingText, ); + if (partial.directionSensitive) { + directionSensitive = true; + } } } } @@ -1681,6 +1712,7 @@ export function matchGrammarCompletion( matchedPrefixLength: maxPrefixLength, separatorMode, closedSet, + directionSensitive, }; debugCompletion(`Completed. ${JSON.stringify(result)}`); return result; diff --git a/ts/packages/actionGrammar/src/nfaCompletion.ts b/ts/packages/actionGrammar/src/nfaCompletion.ts index b7d35ea887..aaba8fc9a9 100644 --- a/ts/packages/actionGrammar/src/nfaCompletion.ts +++ b/ts/packages/actionGrammar/src/nfaCompletion.ts @@ -261,7 +261,7 @@ export function computeNFACompletions( if (reachableStates.length === 0) { debugCompletion(` → no reachable states, returning empty`); - return { completions: [] }; + return { completions: [], directionSensitive: false }; } // Explore completions from reachable states @@ -285,6 +285,7 @@ export function computeNFACompletions( const result: GrammarCompletionResult = { completions: uniqueCompletions, + directionSensitive: false, }; const grammarProperties = buildGrammarProperties(nfa, properties); if (grammarProperties.length > 0) { diff --git a/ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts new file mode 100644 index 0000000000..4f173dea9a --- /dev/null +++ b/ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts @@ -0,0 +1,278 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { loadGrammarRules } from "../src/grammarLoader.js"; +import { matchGrammarCompletion } from "../src/grammarMatcher.js"; + +describe("Grammar Completion - directionSensitive", () => { + describe("single string part", () => { + const g = ` = play music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("not sensitive for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.directionSensitive).toBe(false); + }); + + it("not sensitive for partial first word 'pl'", () => { + const result = matchGrammarCompletion(grammar, "pl"); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive for first word fully typed 'play'", () => { + // "play" fully matched — backward would back up to it. + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for 'play ' with trailing space", () => { + // Trailing space commits — backward same as forward. + const result = matchGrammarCompletion( + grammar, + "play ", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive for exact match 'play music'", () => { + // Exact match with lastMatchedPartInfo — backward would + // back up to "music". + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for exact match with trailing space 'play music '", () => { + const result = matchGrammarCompletion( + grammar, + "play music ", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive when backward on exact match", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "backward", + ); + expect(result.directionSensitive).toBe(true); + }); + }); + + describe("multi-part via nested rule", () => { + const g = [ + ` = $(v:) music -> true;`, + ` = play -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("not sensitive for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive after nested rule consumed 'play'", () => { + // "play" consumed the nested rule — backward could + // reconsider it. + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for 'play ' (trailing space commits)", () => { + const result = matchGrammarCompletion( + grammar, + "play ", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + }); + + describe("wildcard grammar", () => { + const g = [ + ` = play $(song:) -> true;`, + ` = $(x:wildcard);`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("not sensitive for empty input", () => { + const result = matchGrammarCompletion(grammar, ""); + expect(result.directionSensitive).toBe(false); + }); + + it("not sensitive after 'play' (wildcard not yet captured)", () => { + // "play" matched the string part, but the nested wildcard + // rule hasn't captured anything yet. The nested rule + // creates a fresh state without lastMatchedPartInfo. + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive for 'play some song' (wildcard captured)", () => { + // Exact match with wildcard — backward would back up to + // the wildcard. + const result = matchGrammarCompletion( + grammar, + "play some song", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + }); + + describe("Category 3b: partial match with direction", () => { + const g = [ + ` = play some music -> true;`, + ` = play some video -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("sensitive for 'play some music' (multi-word fully matched, no trailing space)", () => { + const result = matchGrammarCompletion( + grammar, + "play some music", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for 'play some music ' (trailing space)", () => { + const result = matchGrammarCompletion( + grammar, + "play some music ", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive for 'play some' (two words matched, Category 3b)", () => { + // "play some" partially matches both rules — "some" is + // fully matched without trailing space, so backward would + // back up. + const result = matchGrammarCompletion( + grammar, + "play some", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for 'play som' (trailing space commits 'play')", () => { + // "play" fully matched but the space after it commits + // that word (trailing separator). "som" is a partial + // match of "some" with no fully matched words at that + // position. So couldBackUp is false. + const result = matchGrammarCompletion( + grammar, + "play som", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + + it("not sensitive for partial first word 'pla'", () => { + const result = matchGrammarCompletion( + grammar, + "pla", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + }); + + describe("varNumber part", () => { + const g = ` = set volume $(level) -> { actionName: "setVolume", parameters: { level } };`; + const grammar = loadGrammarRules("test.grammar", g); + + it("sensitive for 'set volume 42' (number matched)", () => { + const result = matchGrammarCompletion( + grammar, + "set volume 42", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("sensitive backward on 'set volume 42'", () => { + const result = matchGrammarCompletion( + grammar, + "set volume 42", + undefined, + "backward", + ); + expect(result.directionSensitive).toBe(true); + }); + }); + + describe("forward and backward produce same directionSensitive", () => { + const g = ` = play music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("both directions agree on sensitivity for 'play'", () => { + const forward = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + const backward = matchGrammarCompletion( + grammar, + "play", + undefined, + "backward", + ); + expect(forward.directionSensitive).toBe(true); + expect(backward.directionSensitive).toBe(true); + }); + + it("both directions agree on non-sensitivity for 'play '", () => { + const forward = matchGrammarCompletion( + grammar, + "play ", + undefined, + "forward", + ); + const backward = matchGrammarCompletion( + grammar, + "play ", + undefined, + "backward", + ); + expect(forward.directionSensitive).toBe(false); + expect(backward.directionSensitive).toBe(false); + }); + }); +}); diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 3e12b68677..9e92be8b18 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -104,6 +104,11 @@ export type CompletionGroups = { // False or undefined means the parser can continue past // unrecognized input and find more completions. closedSet?: boolean | undefined; + // True when the result would differ if queried with the opposite + // direction. When false, the caller can skip re-fetching on + // direction change. When omitted, the dispatcher will conservatively, + // assume true if matchedPrefixLength > 0 and false otherwise. + directionSensitive?: boolean | undefined; }; export interface AppAgentCommandInterface { diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index 6c48a6fbba..52fff0025c 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -278,6 +278,7 @@ export class GrammarStoreImpl implements GrammarStore { let matchedPrefixLength = 0; let separatorMode: SeparatorMode | undefined; let closedSet: boolean | undefined; + let directionSensitive: boolean = false; const filter = new Set(namespaceKeys); for (const [name, entry] of this.grammars) { if (filter && !filter.has(name)) { @@ -364,6 +365,7 @@ export class GrammarStoreImpl implements GrammarStore { properties.length = 0; separatorMode = undefined; closedSet = undefined; + directionSensitive = false; } if (partialPrefixLength === matchedPrefixLength) { completions.push(...partial.completions); @@ -381,6 +383,10 @@ export class GrammarStoreImpl implements GrammarStore { ? partial.closedSet : closedSet && partial.closedSet; } + // OR-merge: direction-sensitive if any grammar + // result at this prefix length is sensitive. + directionSensitive = + directionSensitive || partial.directionSensitive; if ( partial.properties !== undefined && partial.properties.length > 0 @@ -410,6 +416,7 @@ export class GrammarStoreImpl implements GrammarStore { matchedPrefixLength, separatorMode, closedSet, + directionSensitive, }; } } diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 75ae67fce1..1832186e07 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -91,6 +91,10 @@ export type CompletionResult = { // beyond it. False or undefined means the parser can continue // past unrecognized input and find more completions. closedSet?: boolean | undefined; + // True when the result would differ if queried with the opposite + // direction. When false, the caller can skip re-fetching on + // direction change. + directionSensitive?: boolean | undefined; }; export function mergeCompletionResults( @@ -137,6 +141,13 @@ export function mergeCompletionResults( first.closedSet !== undefined || second.closedSet !== undefined ? (first.closedSet ?? false) && (second.closedSet ?? false) : undefined, + // Direction-sensitive if either source is. + directionSensitive: + first.directionSensitive !== undefined || + second.directionSensitive !== undefined + ? (first.directionSensitive ?? false) || + (second.directionSensitive ?? false) + : undefined, }; } @@ -412,6 +423,12 @@ export class ConstructionCache { // are added (entity values are external). Reset to true when // maxPrefixLength advances (old candidates discarded). let closedSet: boolean = true; + // Direction-sensitive when the opposite direction would produce + // different completions. True when at least one construction + // at maxPrefixLength has matched parts to back up to and the + // prefix doesn't end with a commit signal (separator). + const noTrailingSeparator = !/[\s\p{P}]$/u.test(requestPrefix); + let directionSensitive = false; const rejectReferences = options?.rejectReferences ?? true; const langTools = getLanguageTools("en"); @@ -422,6 +439,7 @@ export class ConstructionCache { completionProperty.length = 0; separatorMode = undefined; closedSet = true; + directionSensitive = false; } } @@ -460,6 +478,9 @@ export class ConstructionCache { // Forward: exact match means nothing to complete. if (partialPartCount === construction.parts.length) { updateMaxPrefixLength(requestPrefix.length); + if (noTrailingSeparator && partialPartCount >= 1) { + directionSensitive = true; + } continue; } completionPart = construction.parts[partialPartCount]; @@ -472,6 +493,12 @@ export class ConstructionCache { continue; // Shorter than the best match — skip } + // Direction-sensitive when a matched part exists to back + // up to and the prefix has no trailing commit signal. + if (noTrailingSeparator && partialPartCount >= 1) { + directionSensitive = true; + } + // --- Step 3: Offer literal completions from the part --- if ( completionPart !== undefined && @@ -568,6 +595,7 @@ export class ConstructionCache { matchedPrefixLength: maxPrefixLength, separatorMode, closedSet, + directionSensitive, }; } diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index 2c5a189c5f..50bca523c4 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -1149,4 +1149,128 @@ describe("ConstructionCache.completion()", () => { }); }); }); + + describe("directionSensitive", () => { + it("false for empty prefix", () => { + const c = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("", defaultOptions); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(false); + }); + + it("false for partial first word (no matched parts)", () => { + const c = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("pla", defaultOptions, "forward"); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(false); + }); + + it("true for fully matched first word without trailing space", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play", defaultOptions, "forward"); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(true); + }); + + it("false when trailing space commits the word", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play ", defaultOptions, "forward"); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(false); + }); + + it("true for exact multi-part match without trailing space", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(true); + }); + + it("false for exact multi-part match with trailing space", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion( + "play song ", + defaultOptions, + "backward", + ); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(false); + }); + + it("both directions agree on sensitivity", () => { + const c = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c]); + const fwd = cache.completion( + "play song", + defaultOptions, + "forward", + ); + const bwd = cache.completion( + "play song", + defaultOptions, + "backward", + ); + expect(fwd).toBeDefined(); + expect(bwd).toBeDefined(); + expect(fwd!.directionSensitive).toBe(true); + expect(bwd!.directionSensitive).toBe(true); + }); + + it("single-part construction is sensitive when fully matched", () => { + const c = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const cache = makeCache([c]); + const result = cache.completion("play", defaultOptions, "backward"); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(true); + }); + }); }); diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index 22277c1a12..28a8e335ae 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -197,6 +197,7 @@ type CompletionTarget = { includeFlags: boolean; booleanFlagName: string | undefined; separatorMode: SeparatorMode | undefined; + directionSensitive: boolean; }; function resolveCompletionTarget( @@ -223,6 +224,7 @@ function resolveCompletionTarget( separatorMode: hasTrailingSpace(input, remainderIndex) ? "optional" : undefined, + directionSensitive: false, }; } @@ -255,6 +257,7 @@ function resolveCompletionTarget( separatorMode: hasTrailingSpace(input, startIndex) ? "optional" : undefined, + directionSensitive: false, }; } } @@ -286,6 +289,7 @@ function resolveCompletionTarget( separatorMode: hasTrailingSpace(input, flagTokenStart) ? "optional" : undefined, + directionSensitive: true, }; } @@ -304,6 +308,7 @@ function resolveCompletionTarget( includeFlags: false, booleanFlagName: undefined, separatorMode: trailingSpace ? "optional" : undefined, + directionSensitive: !trailingSpace, }; } return { @@ -313,6 +318,7 @@ function resolveCompletionTarget( includeFlags: true, booleanFlagName, separatorMode: trailingSpace ? "optional" : undefined, + directionSensitive: false, }; } @@ -431,6 +437,7 @@ async function getCommandParameterCompletion( let agentInvoked = false; let agentClosedSet: boolean | undefined; let separatorMode: SeparatorMode | undefined = target.separatorMode; + let directionSensitive = false; const agent = context.agents.getAppAgent(result.actualAppAgentName); if (agent.getCommandCompletion && target.completionNames.length > 0) { @@ -465,6 +472,11 @@ async function getCommandParameterCompletion( completions.push(...agentResult.groups); agentInvoked = true; agentClosedSet = agentResult.closedSet; + // Default: direction-sensitive when agent consumed input + // (matchedPrefixLength > 0), not sensitive otherwise. + directionSensitive = + agentResult.directionSensitive ?? + (groupPrefixLength !== undefined && groupPrefixLength > 0); debug( `Command completion parameter with agent: groupPrefixLength=${groupPrefixLength}, startIndex=${startIndex}`, ); @@ -480,6 +492,7 @@ async function getCommandParameterCompletion( target.isPartialValue, params.nextArgs.length > 0, ), + directionSensitive: target.directionSensitive || directionSensitive, }; } @@ -497,6 +510,7 @@ async function completeDescriptor( startIndex: number | undefined; separatorMode: SeparatorMode | undefined; closedSet: boolean; + directionSensitive: boolean; }> { const completions: CompletionGroup[] = []; let separatorMode: SeparatorMode | undefined; @@ -537,6 +551,7 @@ async function completeDescriptor( startIndex: undefined, separatorMode, closedSet: true, + directionSensitive: false, }; } @@ -549,6 +564,7 @@ async function completeDescriptor( parameterCompletions.separatorMode, ), closedSet: parameterCompletions.closedSet, + directionSensitive: parameterCompletions.directionSensitive, }; } @@ -655,6 +671,9 @@ export async function getCommandCompletion( ? "optional" : undefined; let closedSet = true; + // Track whether direction influenced the result. When false, + // the caller can skip re-fetching on direction change. + let directionSensitive = false; const descriptor = result.descriptor; @@ -671,13 +690,17 @@ export async function getCommandCompletion( // the normalized command ends with whitespace, which indicates // the resolver already considers the last token committed. const normalizedCommitted = /\s$/.test(partialCommand); - const uncommittedCommand = + // Direction matters at the command level when the command is + // exactly matched and could be either "committed to" (forward) + // or "reconsidered" (backward). + const directionSensitiveCommand = descriptor !== undefined && result.matched && - direction === "backward" && !normalizedCommitted && result.suffix === "" && table !== undefined; + const uncommittedCommand = + directionSensitiveCommand && direction === "backward"; if (uncommittedCommand) { const lastCmd = result.commands[result.commands.length - 1]; @@ -687,6 +710,7 @@ export async function getCommandCompletion( completions: Object.keys(table!.commands), }); separatorMode = mergeSeparatorMode(separatorMode, "none"); + directionSensitive = true; // closedSet stays true: subcommand names are exhaustive. } else if (descriptor !== undefined) { const desc = await completeDescriptor( @@ -706,6 +730,11 @@ export async function getCommandCompletion( desc.separatorMode, ); closedSet = desc.closedSet; + // Direction-sensitive if the command level is (would have + // taken the uncommittedCommand branch with opposite + // direction) or if the agent/parameter level is. + directionSensitive = + directionSensitiveCommand || desc.directionSensitive; } else if (table !== undefined) { // descriptor is undefined: the suffix didn't resolve to any // known command or subcommand. startIndex already points to @@ -770,6 +799,7 @@ export async function getCommandCompletion( completions, separatorMode, closedSet, + directionSensitive, }; debug(`Command completion result:`, completionResult); @@ -783,6 +813,7 @@ export async function getCommandCompletion( completions: [], separatorMode: undefined, closedSet: false, + directionSensitive: false, }; } } diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index 24a09cfd8c..6297509776 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -104,6 +104,7 @@ export async function requestCompletion( const matchedPrefixLength = results.matchedPrefixLength; const separatorMode = results.separatorMode; const closedSet = results.closedSet; + const directionSensitive = results.directionSensitive; const completions: CompletionGroup[] = []; if (results.completions.length > 0) { completions.push({ @@ -120,6 +121,7 @@ export async function requestCompletion( matchedPrefixLength, separatorMode, closedSet, + directionSensitive, }; } @@ -145,6 +147,7 @@ export async function requestCompletion( matchedPrefixLength, separatorMode, closedSet, + directionSensitive, }; } diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index 594c6b7743..f64ac046ce 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -1607,4 +1607,76 @@ describe("Command Completion - startIndex", () => { expect(result.completions.length).toBeGreaterThan(0); }); }); + + describe("directionSensitive", () => { + it("is true for '@comptest run' (exact subcommand match)", async () => { + const result = await getCommandCompletion( + "@comptest run", + "forward", + context, + ); + // "run" exactly matches a subcommand — backward would + // reconsider, so the result is direction-sensitive. + expect(result.directionSensitive).toBe(true); + }); + + it("is true for '@comptest run' backward", async () => { + const result = await getCommandCompletion( + "@comptest run", + "backward", + context, + ); + expect(result.directionSensitive).toBe(true); + }); + + it("is false for '@comptest run ' with trailing space (committed)", async () => { + const result = await getCommandCompletion( + "@comptest run ", + "forward", + context, + ); + // Trailing space commits the subcommand — direction no + // longer matters at the command level. + expect(result.directionSensitive).toBeFalsy(); + }); + + it("is true for '@comptest flagsonly --level' (pending flag, no trailing space)", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --level", + "forward", + context, + ); + // "--level" is a non-boolean flag without trailing space. + // Backward would back up to flag alternatives. + expect(result.directionSensitive).toBe(true); + }); + + it("is false for '@comptest flagsonly --level ' (trailing space commits flag)", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --level ", + "forward", + context, + ); + // Trailing space commits the flag — direction doesn't matter. + expect(result.directionSensitive).toBeFalsy(); + }); + + it("is false for empty input", async () => { + const result = await getCommandCompletion("", "forward", context); + // Empty input: normalizeCommand inserts implicit tokens + // that are inherently committed. + expect(result.directionSensitive).toBeFalsy(); + }); + + it("is false for '@comptest flagsonly --debug' (boolean flag, no pending value)", async () => { + const result = await getCommandCompletion( + "@comptest flagsonly --debug", + "forward", + context, + ); + // "--debug" is boolean — fully consumed, no pending flag. + // No direction-sensitive branch applies. + expect(result.directionSensitive).toBeFalsy(); + }); + }); }); diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index e77177e894..a2ca0677e8 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -89,6 +89,10 @@ export type CommandCompletionResult = { // prefix-match any completion, the caller can skip refetching since // no other valid input exists. closedSet: boolean; + // True when the result would differ if queried with the opposite + // direction. When false, the caller can skip re-fetching on + // direction change. + directionSensitive: boolean; }; export type AppAgentStatus = { diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 92b8befec5..ee4fdd9009 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -75,6 +75,10 @@ export class PartialCompletionSession { // Saved as-is from the last completion result. private separatorMode: SeparatorMode = "space"; private closedSet: boolean = false; + // True when completions differ between forward and backward. + private directionSensitive: boolean = false; + // Direction used for the last fetch. + private lastDirection: CompletionDirection = "forward"; // The in-flight completion request, or undefined when settled. private completionP: Promise | undefined; @@ -94,7 +98,7 @@ export class PartialCompletionSession { getPosition: (prefix: string) => SearchMenuPosition | undefined, direction: CompletionDirection = "forward", ): void { - if (this.reuseSession(input, getPosition)) { + if (this.reuseSession(input, getPosition, direction)) { return; } @@ -182,9 +186,16 @@ export class PartialCompletionSession { // 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. + // 7. Direction changed — the user switched between forward and backward + // AND the last result was direction-sensitive + // AND the input is at the exact anchor (no text + // typed past it). Once the user types past the + // anchor, the direction-sensitive boundary has been + // passed and the loaded completions are still valid. private reuseSession( input: string, getPosition: (prefix: string) => SearchMenuPosition | undefined, + direction: CompletionDirection = "forward", ): boolean { // [A1] No session — IDLE state, must fetch. if (this.anchor === undefined) { @@ -201,6 +212,30 @@ export class PartialCompletionSession { // ACTIVE from here. const { anchor, separatorMode: sepMode, closedSet } = this; + // [A7] Direction changed on a direction-sensitive result. + // The loaded completions were computed for the opposite direction + // and would differ — but only at the anchor boundary itself. + // Once the user has typed past the anchor (rawPrefix is + // non-empty), the direction-sensitive point has been passed: + // the trailing text acts as a commit signal, and backward is + // neutralized by the content after the anchor. The loaded + // completions are still valid for trie filtering. + // + // If input is shorter than anchor, A2 (anchor diverged) will + // catch it. If input is longer but the separator isn't + // satisfied, A3 will catch it. So this check only needs to + // handle the exact-anchor case. + if ( + direction !== this.lastDirection && + this.directionSensitive && + input === anchor + ) { + debug( + `Partial completion re-fetch: direction changed (${this.lastDirection} → ${direction}), directionSensitive`, + ); + return false; + } + // [A2] RE-FETCH — input moved past the anchor (e.g. backspace, new word). if (!input.startsWith(anchor)) { debug( @@ -346,6 +381,8 @@ export class PartialCompletionSession { this.separatorMode = result.separatorMode ?? "space"; this.closedSet = result.closedSet; + this.directionSensitive = result.directionSensitive; + this.lastDirection = direction; const completions = toMenuItems(result.completions); diff --git a/ts/packages/shell/test/partialCompletion/direction.spec.ts b/ts/packages/shell/test/partialCompletion/direction.spec.ts index 52ba7cb5b5..f7a4c31b8f 100644 --- a/ts/packages/shell/test/partialCompletion/direction.spec.ts +++ b/ts/packages/shell/test/partialCompletion/direction.spec.ts @@ -175,6 +175,105 @@ describe("PartialCompletionSession — direction-based completion", () => { "forward", ); }); + + test("direction change re-fetches when directionSensitive is true", async () => { + const menu = makeMenu(); + // startIndex=5 so anchor = "play " (the full initial input). + const result = makeCompletionResult(["song", "track"], 5, { + separatorMode: "none", + directionSensitive: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos, "forward"); + await Promise.resolve(); // → ACTIVE, anchor = "play " + + // Same input, different direction, at exact anchor → re-fetch + session.update("play ", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play ", + "backward", + ); + }); + + test("direction change reuses when past anchor boundary", async () => { + const menu = makeMenu(); + // startIndex=4 so anchor = "play"; "play " extends past anchor. + const result = makeCompletionResult(["song", "track"], 4, { + separatorMode: "space", + directionSensitive: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos, "forward"); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // Different direction but input extends past anchor — the + // direction-sensitive boundary has been passed; reuse. + session.update("play ", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("direction change reuses when directionSensitive is false", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song", "track"], 4, { + separatorMode: "space", + directionSensitive: false, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos, "forward"); + await Promise.resolve(); // → ACTIVE + + // Same input, different direction, but not sensitive → reuse + session.update("play ", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("same direction reuses even when directionSensitive is true", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song", "track"], 4, { + separatorMode: "space", + directionSensitive: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos, "forward"); + await Promise.resolve(); // → ACTIVE + + // Same direction → reuse, no re-fetch needed + session.update("play ", getPos, "forward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("direction change with extended input reuses (past anchor boundary)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song", "track"], 4, { + separatorMode: "space", + directionSensitive: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play ", getPos, "forward"); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // User typed further past the anchor, then changed direction. + // The direction-sensitive boundary was at "play"; the user has + // committed past it, so the loaded completions are still valid. + session.update("play so", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); }); // ── committed-past-boundary (hasExactMatch) ─────────────────────────────────── diff --git a/ts/packages/shell/test/partialCompletion/helpers.ts b/ts/packages/shell/test/partialCompletion/helpers.ts index 1b61fcad89..5a1800dc9d 100644 --- a/ts/packages/shell/test/partialCompletion/helpers.ts +++ b/ts/packages/shell/test/partialCompletion/helpers.ts @@ -64,6 +64,7 @@ export function makeDispatcher( completions: [], separatorMode: undefined, closedSet: true, + directionSensitive: false, }, ): MockDispatcher { return { @@ -86,6 +87,7 @@ export function makeCompletionResult( startIndex, completions: [group], closedSet: false, + directionSensitive: false, ...opts, }; } diff --git a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts index 8cd3c30001..28c4cb85fc 100644 --- a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts +++ b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts @@ -44,6 +44,7 @@ describe("PartialCompletionSession — result processing", () => { startIndex: 4, completions: [group1, group2], closedSet: false, + directionSensitive: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -75,6 +76,7 @@ describe("PartialCompletionSession — result processing", () => { startIndex: 4, completions: [group], closedSet: false, + directionSensitive: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -103,6 +105,7 @@ describe("PartialCompletionSession — result processing", () => { startIndex: 0, completions: [group], closedSet: false, + directionSensitive: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -123,6 +126,7 @@ describe("PartialCompletionSession — result processing", () => { startIndex: 0, completions: [{ name: "empty", completions: [] }], closedSet: false, + directionSensitive: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -147,6 +151,7 @@ describe("PartialCompletionSession — result processing", () => { startIndex: 0, completions: [group], closedSet: false, + directionSensitive: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -180,6 +185,7 @@ describe("PartialCompletionSession — result processing", () => { startIndex: 0, completions: [group], closedSet: false, + directionSensitive: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -209,6 +215,7 @@ describe("PartialCompletionSession — result processing", () => { startIndex: 0, completions: [sortedGroup, unsortedGroup], closedSet: false, + directionSensitive: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); diff --git a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts index e1268b3442..0ab79a49e6 100644 --- a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts +++ b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts @@ -67,6 +67,7 @@ function makeSequentialDispatcher( startIndex: 0, completions: [], closedSet: true, + directionSensitive: false, }); return { getCommandCompletion: fn }; } From 8f3068b5c3343905267ccafd6c1abb1fbf077b1f Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 17 Mar 2026 19:24:21 -0700 Subject: [PATCH 4/7] Improve naming, terminology, and comment consistency in completion system - Fix stale CompletionDirection comment (described 'current'/'next' but values are 'forward'/'backward') - Fix garbled escape sequences in partialCompletionSession update() comment - Remove dead default parameter from reuseSession() private method - Unify 'trailing separator' / 'commit signal' terminology in dispatcher - Fix stray comma in directionSensitive comment - Add directionSensitive to CommandCompletionResult type block in docs - Rename uncommittedCommand to reconsideringCommand for direction model - Add inline comment at isEditingFreeFormValue call site - Update grammar category table to show backward direction behavior - Rename canBackward to couldBackUp for consistency with tryPartialStringMatch - Move A7 (direction changed) from section C to section A in reuseSession comment and add it to completion.md table --- ts/docs/architecture/completion.md | 16 +++++++++------- .../actionGrammar/src/grammarMatcher.ts | 10 +++++----- ts/packages/agentSdk/src/command.ts | 14 +++++++------- .../dispatcher/src/command/completion.ts | 17 +++++++++-------- .../renderer/src/partialCompletionSession.ts | 19 ++++++++++--------- 5 files changed, 40 insertions(+), 36 deletions(-) diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index 5d49d3d74e..34e66faa53 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -76,6 +76,7 @@ The return path carries `CommandCompletionResult`: completions: CompletionGroup[]; separatorMode?: SeparatorMode; // "space" | "spacePunctuation" | "optional" | "none" closedSet: boolean; // true → list is exhaustive + directionSensitive: boolean; // true → opposite direction would produce different results } ``` @@ -99,12 +100,12 @@ grammar rules. 3. When `maxPrefixLength` advances, discard all shorter-prefix completions. 4. Categorize each state's outcome: -| Category | Condition | Completion source | -| ------------------ | ------------------------------------------- | ------------------------------ | -| 1 — Exact | Rule fully matched, prefix fully consumed | None (rule satisfied) | -| 2 — Clean partial | Prefix consumed, rule has remaining parts | Next part of rule | -| 3a — Dirty partial | Trailing text matches start of current part | Current part (prefix-filtered) | -| 3b — Dirty partial | Trailing text doesn't match | Offer current part | +| Category | Condition | Completion source | +| ------------------ | ------------------------------------------- | ----------------------------------------------------- | +| 1 — Exact | Rule fully matched, prefix fully consumed | None (forward); last matched word/wildcard (backward) | +| 2 — Clean partial | Prefix consumed, rule has remaining parts | Next part (forward); last matched part (backward) | +| 3a — Dirty partial | Trailing text matches start of current part | Current part (prefix-filtered) | +| 3b — Dirty partial | Trailing text doesn't match | Current part (forward); last matched part (backward) | 5. Multi-word string parts use `tryPartialStringMatch()` to offer one word at a time instead of the entire phrase. @@ -200,7 +201,7 @@ input → normalizeCommand() → resolveCommand() → ResolveCommandResult { descriptor, suffix, table, matched } → three-way branch: - ├─ uncommitted command → sibling subcommands (separatorMode="none") + ├─ reconsidering command → sibling subcommands (separatorMode="none") ├─ resolved descriptor → completeDescriptor() │ ├─ parseParams(suffix, partial=true) → ParseParamsResult │ ├─ resolveCompletionTarget() → CompletionTarget @@ -264,6 +265,7 @@ lifecycle of a completion interaction. | A1 | No active session | Invalidation | Re-fetch | | A2 | Input no longer extends anchor | Invalidation | Re-fetch | | A3 | Non-separator char typed when separator required | Invalidation | Re-fetch | +| A7 | Direction changed on direction-sensitive result | Invalidation | Re-fetch | | B4 | Unique match (always fires) | Navigation | Re-fetch next level | | B5 | Separator typed after exact match | Navigation | Re-fetch next level | | C6 | No trie matches + open set | Discovery | Re-fetch | diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index ba8491a777..e99b67d99b 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -1566,7 +1566,7 @@ export function matchGrammarCompletion( // Would backward produce different results than forward? // True when the prefix was fully consumed and there is a // matched part (string/number) or wildcard to back up to. - const canBackward = + const couldBackUp = state.index >= prefix.length && (savedPendingWildcard?.valueId !== undefined || state.lastMatchedPartInfo !== undefined); @@ -1576,7 +1576,7 @@ export function matchGrammarCompletion( if (matched) { if ( direction === "backward" && - canBackward && + couldBackUp && emitBackwardCompletion(state, savedPendingWildcard) ) { // Backward emitted a completion — done with this state. @@ -1584,7 +1584,7 @@ export function matchGrammarCompletion( debugCompletion("Matched. Nothing to complete."); updateMaxPrefixLength(state.index); } - if (canBackward) { + if (couldBackUp) { directionSensitive = true; } continue; @@ -1598,7 +1598,7 @@ export function matchGrammarCompletion( if ( direction === "backward" && - canBackward && + couldBackUp && emitBackwardCompletion(state, savedPendingWildcard) ) { // Backward emitted a completion — done with this state. @@ -1622,7 +1622,7 @@ export function matchGrammarCompletion( } } } - if (canBackward) { + if (couldBackUp) { directionSensitive = true; } // Note: non-string next parts (wildcard, number, rules) in diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 9e92be8b18..7db9ba4eae 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -67,12 +67,12 @@ export type CommandDescriptors = export type SeparatorMode = "space" | "spacePunctuation" | "optional" | "none"; // Indicates the user's editing direction, provided by the host. -// "current" — the user is still editing the last token (e.g. appending -// characters, or just deleted/backspaced). The backend should -// offer completions that replace/extend the current token. -// "next" — the user has committed the last token (e.g. typed a -// separator, selected a menu item). The backend should offer -// completions for the next position. +// "forward" — the user is moving ahead (appending characters, +// typed a separator, selected a menu item). The backend +// should offer completions for what follows. +// "backward" — the user is reconsidering (e.g. backspaced). The +// backend should offer alternatives for the current +// position. export type CompletionDirection = "forward" | "backward"; export type CompletionGroup = { @@ -106,7 +106,7 @@ export type CompletionGroups = { closedSet?: boolean | undefined; // True when the result would differ if queried with the opposite // direction. When false, the caller can skip re-fetching on - // direction change. When omitted, the dispatcher will conservatively, + // direction change. When omitted, the dispatcher will conservatively // assume true if matchedPrefixLength > 0 and false otherwise. directionSensitive?: boolean | undefined; }; diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index 28a8e335ae..b66f1e9157 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -75,10 +75,11 @@ function detectPendingFlag( } // True when text[0..index) ends with whitespace — i.e., the user -// has typed a trailing space after the last token. This serves as -// a commit signal: the token before the space is committed and the -// space itself is consumed, so startIndex should include it and -// separatorMode should be "optional" (no additional separator needed). +// has typed a trailing separator after the last token. A trailing +// separator acts as a commit signal: the token before it is +// considered committed and the separator itself is consumed, so +// startIndex should include it and separatorMode should be +// "optional" (no additional separator needed). function hasTrailingSpace(text: string, index: number): boolean { return index > 0 && /\s/.test(text[index - 1]); } @@ -243,7 +244,7 @@ function resolveCompletionTarget( isEditingFreeFormValue( quoted, lastParamImplicitQuotes, - !/\s$/.test(input), + !/\s$/.test(input), // true when input ends mid-token (no trailing space) pendingFlag, ) ) { @@ -699,10 +700,10 @@ export async function getCommandCompletion( !normalizedCommitted && result.suffix === "" && table !== undefined; - const uncommittedCommand = + const reconsideringCommand = directionSensitiveCommand && direction === "backward"; - if (uncommittedCommand) { + if (reconsideringCommand) { const lastCmd = result.commands[result.commands.length - 1]; startIndex = commandConsumedLength - lastCmd.length; completions.push({ @@ -731,7 +732,7 @@ export async function getCommandCompletion( ); closedSet = desc.closedSet; // Direction-sensitive if the command level is (would have - // taken the uncommittedCommand branch with opposite + // taken the reconsideringCommand branch with opposite // direction) or if the agent/parameter level is. directionSensitive = directionSensitiveCommand || desc.directionSensitive; diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index ee4fdd9009..7ab1604ab6 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -90,7 +90,8 @@ export class PartialCompletionSession { // Main entry point. Called by PartialCompletion.update() after DOM checks pass. // input: trimmed input text (ghost text stripped, leading whitespace stripped) - // direction: host-provided signal: \"forward\" (user is moving ahead) or\n // \"backward\" (user is reconsidering, e.g. backspaced) + // direction: host-provided signal: "forward" (user is moving ahead) or + // "backward" (user is reconsidering, e.g. backspaced) // getPosition: DOM callback that computes the menu anchor position; returns // undefined when position cannot be determined (hides menu). public update( @@ -169,6 +170,12 @@ export class PartialCompletionSession { // immediately after anchor, but a non-separator // character was typed instead. The constraint can // never be satisfied, so treat as new input. + // 7. Direction changed — the user switched between forward and backward + // AND the last result was direction-sensitive + // AND the input is at the exact anchor (no text + // typed past it). Once the user types past the + // anchor, the direction-sensitive boundary has been + // passed and the loaded completions are still valid. // // B. Hierarchical navigation — user completed this level; re-fetch for // the NEXT level's completions. @@ -186,16 +193,10 @@ export class PartialCompletionSession { // 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. - // 7. Direction changed — the user switched between forward and backward - // AND the last result was direction-sensitive - // AND the input is at the exact anchor (no text - // typed past it). Once the user types past the - // anchor, the direction-sensitive boundary has been - // passed and the loaded completions are still valid. private reuseSession( input: string, getPosition: (prefix: string) => SearchMenuPosition | undefined, - direction: CompletionDirection = "forward", + direction: CompletionDirection, ): boolean { // [A1] No session — IDLE state, must fetch. if (this.anchor === undefined) { @@ -415,7 +416,7 @@ export class PartialCompletionSession { // Re-run update with captured input to show the menu (or defer // if the separator has not been typed yet). - this.reuseSession(input, getPosition); + this.reuseSession(input, getPosition, direction); }) .catch((e) => { debugError(`Partial completion error: '${input}' ${e}`); From 1516a5d1f3464ea0f1d8e7919daf1e4cf9858de8 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 17 Mar 2026 19:49:59 -0700 Subject: [PATCH 5/7] Add breadcrumbs to docs/architecture/completion.md in completion source files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 'Architecture: docs/architecture/completion.md' comments pointing to the relevant section in each source file that implements the completion system: - §1 Grammar Matcher: actionGrammar/grammarMatcher.ts - §2 Cache Layer: cache constructionCache, cache.ts, grammarStore, constructionStore - §3 Agent SDK: command.ts types, commandHelpers.ts - §4 Dispatcher: dispatcher completion.ts - §5 Shell Completion Session: partialCompletionSession.ts - §6 Shell DOM Adapter: partial.ts - §7 Shell Search Menu: searchMenuBase.ts, search.ts - CLI integration: cli interactive.ts - Data flow / Key types: dispatcher types dispatcher.ts --- ts/docs/architecture/completion.md | 12 ++--- .../actionGrammar/src/grammarMatcher.ts | 37 ++++++------- ts/packages/agentSdk/src/command.ts | 3 ++ .../agentSdk/src/helpers/commandHelpers.ts | 1 + ts/packages/cache/src/cache/cache.ts | 3 +- .../cache/src/cache/constructionStore.ts | 3 +- ts/packages/cache/src/cache/grammarStore.ts | 7 +-- .../src/constructions/constructionCache.ts | 9 ++-- ts/packages/cli/src/commands/interactive.ts | 1 + .../dispatcher/src/command/completion.ts | 52 ++++++++++--------- .../src/translation/requestCompletion.ts | 2 +- .../dispatcher/test/completion.spec.ts | 4 +- .../dispatcher/types/src/dispatcher.ts | 1 + ts/packages/shell/src/renderer/src/partial.ts | 1 + .../renderer/src/partialCompletionSession.ts | 1 + ts/packages/shell/src/renderer/src/search.ts | 1 + .../shell/src/renderer/src/searchMenuBase.ts | 1 + 17 files changed, 77 insertions(+), 62 deletions(-) diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index 34e66faa53..6156e12d0b 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -100,12 +100,12 @@ grammar rules. 3. When `maxPrefixLength` advances, discard all shorter-prefix completions. 4. Categorize each state's outcome: -| Category | Condition | Completion source | -| ------------------ | ------------------------------------------- | ----------------------------------------------------- | -| 1 — Exact | Rule fully matched, prefix fully consumed | None (forward); last matched word/wildcard (backward) | -| 2 — Clean partial | Prefix consumed, rule has remaining parts | Next part (forward); last matched part (backward) | -| 3a — Dirty partial | Trailing text matches start of current part | Current part (prefix-filtered) | -| 3b — Dirty partial | Trailing text doesn't match | Current part (forward); last matched part (backward) | +| Category | Condition | Forward completion source | Backward completion source | +| ------------------ | ------------------------------------------- | ------------------------------ | ------------------------------ | +| 1 — Exact | Rule fully matched, prefix fully consumed | None | Last matched word/wildcard | +| 2 — Clean partial | Prefix consumed, rule has remaining parts | Next part of rule | Last matched part | +| 3a — Dirty partial | Trailing text matches start of current part | Current part (prefix-filtered) | Current part (prefix-filtered) | +| 3b — Dirty partial | Trailing text doesn't match | Current part | Last matched part | 5. Multi-word string parts use `tryPartialStringMatch()` to offer one word at a time instead of the entire phrase. diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index e99b67d99b..312148597c 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -211,6 +211,14 @@ type ParentMatchState = { repeatPartIndex?: number | undefined; // defined for ()* / )+ — holds the part index to loop back to spacingMode: CompiledSpacingMode; // parent rule's spacingMode, restored in MatchState on return from nested rule }; + +// A wildcard slot awaiting its capture value. Used on MatchState.pendingWildcard +// and saved across finalizeState calls for backward completion. +type PendingWildcard = { + readonly start: number; + readonly valueId: number | undefined; +}; + type MatchState = { // Current context name: string; // For debugging @@ -231,12 +239,7 @@ type MatchState = { spacingMode: CompiledSpacingMode; // active spacing mode for this rule index: number; - pendingWildcard?: - | { - readonly start: number; - readonly valueId: number | undefined; - } - | undefined; + pendingWildcard?: PendingWildcard | undefined; // Completion support: tracks the last matched non-wildcard part // (string or number). Used by backward completion to back up to @@ -1365,6 +1368,8 @@ function tryPartialStringMatch( * 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. + * + * Architecture: docs/architecture/completion.md — §1 Grammar Matcher */ export function matchGrammarCompletion( grammar: Grammar, @@ -1486,7 +1491,7 @@ export function matchGrammarCompletion( // or emitPropertyCompletion (for numbers). function emitBackwardCompletion( state: MatchState, - savedWildcard: typeof savedPendingWildcard, + savedWildcard: PendingWildcard | undefined, ): boolean { const wildcardStart = savedWildcard?.start; const partStart = state.lastMatchedPartInfo?.start; @@ -1533,11 +1538,6 @@ export function matchGrammarCompletion( return false; } - // Keep savedPendingWildcard type available for the helper above. - let savedPendingWildcard: - | { readonly start: number; readonly valueId: number | undefined } - | undefined; - // --- Main loop: process every pending state --- while (pending.length > 0) { const state = pending.pop()!; @@ -1553,7 +1553,8 @@ export function matchGrammarCompletion( // Save the pending wildcard before finalizeState clears it. // Needed for backward completion of wildcards at the end of a rule. - savedPendingWildcard = state.pendingWildcard; + const savedPendingWildcard: PendingWildcard | undefined = + state.pendingWildcard; // finalizeState does two things: // 1. If a wildcard is pending at the end, attempt to capture @@ -1566,7 +1567,7 @@ export function matchGrammarCompletion( // Would backward produce different results than forward? // True when the prefix was fully consumed and there is a // matched part (string/number) or wildcard to back up to. - const couldBackUp = + const hasPartToReconsider = state.index >= prefix.length && (savedPendingWildcard?.valueId !== undefined || state.lastMatchedPartInfo !== undefined); @@ -1576,7 +1577,7 @@ export function matchGrammarCompletion( if (matched) { if ( direction === "backward" && - couldBackUp && + hasPartToReconsider && emitBackwardCompletion(state, savedPendingWildcard) ) { // Backward emitted a completion — done with this state. @@ -1584,7 +1585,7 @@ export function matchGrammarCompletion( debugCompletion("Matched. Nothing to complete."); updateMaxPrefixLength(state.index); } - if (couldBackUp) { + if (hasPartToReconsider) { directionSensitive = true; } continue; @@ -1598,7 +1599,7 @@ export function matchGrammarCompletion( if ( direction === "backward" && - couldBackUp && + hasPartToReconsider && emitBackwardCompletion(state, savedPendingWildcard) ) { // Backward emitted a completion — done with this state. @@ -1622,7 +1623,7 @@ export function matchGrammarCompletion( } } } - if (couldBackUp) { + if (hasPartToReconsider) { directionSensitive = true; } // Note: non-string next parts (wildcard, number, rules) in diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 7db9ba4eae..0ffbb219b2 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -1,6 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Completion types (SeparatorMode, CompletionDirection, CompletionGroups, +// getCommandCompletion): docs/architecture/completion.md — §3 Agent SDK + import { ActionContext, SessionContext } from "./agentInterface.js"; import { ParameterDefinitions, ParsedCommandParams } from "./parameters.js"; diff --git a/ts/packages/agentSdk/src/helpers/commandHelpers.ts b/ts/packages/agentSdk/src/helpers/commandHelpers.ts index 6f7b94efc7..83c35b48bc 100644 --- a/ts/packages/agentSdk/src/helpers/commandHelpers.ts +++ b/ts/packages/agentSdk/src/helpers/commandHelpers.ts @@ -15,6 +15,7 @@ import { // 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. +// Architecture: docs/architecture/completion.md — §3 Agent SDK export function mergeSeparatorMode( a: SeparatorMode | undefined, b: SeparatorMode | undefined, diff --git a/ts/packages/cache/src/cache/cache.ts b/ts/packages/cache/src/cache/cache.ts index a57b7061b3..619e41d90a 100644 --- a/ts/packages/cache/src/cache/cache.ts +++ b/ts/packages/cache/src/cache/cache.ts @@ -611,10 +611,11 @@ export class AgentCache { return grammarStore.match(request, options); } + // Architecture: docs/architecture/completion.md — §2 Cache Layer public completion( requestPrefix: string, options?: MatchOptions, - direction?: CompletionDirection, + direction?: CompletionDirection, // defaults to forward-like behavior when omitted ): CompletionResult | undefined { // If NFA grammar system is configured, only use grammar store if (this._useNFAGrammar) { diff --git a/ts/packages/cache/src/cache/constructionStore.ts b/ts/packages/cache/src/cache/constructionStore.ts index 291cd89d08..d561201966 100644 --- a/ts/packages/cache/src/cache/constructionStore.ts +++ b/ts/packages/cache/src/cache/constructionStore.ts @@ -396,10 +396,11 @@ export class ConstructionStoreImpl implements ConstructionStore { return sortedMatches; } + // Architecture: docs/architecture/completion.md — §2 Cache Layer public completion( requestPrefix: string, options?: MatchOptions, - direction?: CompletionDirection, + direction?: CompletionDirection, // defaults to forward-like behavior when omitted ) { const cacheCompletion = this.cache?.completion( requestPrefix, diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index 52fff0025c..de2b04f194 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -261,10 +261,11 @@ export class GrammarStoreImpl implements GrammarStore { return sortMatches(matches); } + // Architecture: docs/architecture/completion.md — §2 Cache Layer public completion( requestPrefix: string, options?: MatchOptions, - direction?: CompletionDirection, + direction?: CompletionDirection, // defaults to forward-like behavior when omitted ): CompletionResult | undefined { if (!this.enabled) { return undefined; @@ -383,8 +384,8 @@ export class GrammarStoreImpl implements GrammarStore { ? partial.closedSet : closedSet && partial.closedSet; } - // OR-merge: direction-sensitive if any grammar - // result at this prefix length is sensitive. + // True if any grammar result at this prefix + // length is direction-sensitive. directionSensitive = directionSensitive || partial.directionSensitive; if ( diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 1832186e07..2a9f8a7938 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -97,6 +97,7 @@ export type CompletionResult = { directionSensitive?: boolean | undefined; }; +// Architecture: docs/architecture/completion.md — §2 Cache Layer export function mergeCompletionResults( first: CompletionResult | undefined, second: CompletionResult | undefined, @@ -388,16 +389,14 @@ export class ConstructionCache { public completion( requestPrefix: string, options?: MatchOptions, - direction?: CompletionDirection, + direction?: CompletionDirection, // defaults to forward-like behavior when omitted ): CompletionResult | undefined { debugCompletion(`Request completion for prefix: '${requestPrefix}'`); const namespaceKeys = options?.namespaceKeys; debugCompletion(`Request completion namespace keys`, namespaceKeys); - // Trailing separator (whitespace or punctuation) is a commit - // signal: the token before it is committed and direction no - // longer matters. Neutralize backward so the matcher doesn't - // back up. + // Resolve direction to a boolean: true when the user is actively + // backing up and no trailing separator has committed the last token. const backward = direction === "backward" && !/[\s\p{P}]$/u.test(requestPrefix); const results = this.match(requestPrefix, options, true, backward); diff --git a/ts/packages/cli/src/commands/interactive.ts b/ts/packages/cli/src/commands/interactive.ts index e4b0276209..72091e3f4f 100644 --- a/ts/packages/cli/src/commands/interactive.ts +++ b/ts/packages/cli/src/commands/interactive.ts @@ -48,6 +48,7 @@ type CompletionData = { prefix: string; // Fixed prefix before completions }; +// Architecture: docs/architecture/completion.md — §CLI integration async function getCompletionsData( line: string, dispatcher: Dispatcher, diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index b66f1e9157..b355e6021d 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// Architecture: docs/architecture/completion.md — §4 Dispatcher + import { CommandHandlerContext } from "../context/commandHandlerContext.js"; import { @@ -75,12 +77,12 @@ function detectPendingFlag( } // True when text[0..index) ends with whitespace — i.e., the user -// has typed a trailing separator after the last token. A trailing -// separator acts as a commit signal: the token before it is -// considered committed and the separator itself is consumed, so +// has typed trailing whitespace after the last token. Trailing +// whitespace acts as a commit signal: the token before it is +// considered committed and the whitespace itself is consumed, so // startIndex should include it and separatorMode should be // "optional" (no additional separator needed). -function hasTrailingSpace(text: string, index: number): boolean { +function hasWhitespaceBefore(text: string, index: number): boolean { return index > 0 && /\s/.test(text[index - 1]); } @@ -222,7 +224,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: true, booleanFlagName, - separatorMode: hasTrailingSpace(input, remainderIndex) + separatorMode: hasWhitespaceBefore(input, remainderIndex) ? "optional" : undefined, directionSensitive: false, @@ -244,7 +246,7 @@ function resolveCompletionTarget( isEditingFreeFormValue( quoted, lastParamImplicitQuotes, - !/\s$/.test(input), // true when input ends mid-token (no trailing space) + !/\s$/.test(input), // true when input ends mid-token (no trailing whitespace) pendingFlag, ) ) { @@ -255,7 +257,7 @@ function resolveCompletionTarget( isPartialValue: true, includeFlags: false, booleanFlagName: undefined, - separatorMode: hasTrailingSpace(input, startIndex) + separatorMode: hasWhitespaceBefore(input, startIndex) ? "optional" : undefined, directionSensitive: false, @@ -270,14 +272,14 @@ function resolveCompletionTarget( // names. isPartialValue is false: flag names are an enumerable // set. // - // Trailing space commits the flag — direction no longer matters. - // When the user typed "--level " (with space), they've moved on; + // Trailing whitespace commits the flag — direction no longer matters. + // When the user typed "--level " (with whitespace), they've moved on; // fall through to 3b for value completions regardless of direction. - const trailingSpace = hasTrailingSpace(input, remainderIndex); + const trailingWhitespace = hasWhitespaceBefore(input, remainderIndex); if ( pendingFlag !== undefined && direction === "backward" && - !trailingSpace + !trailingWhitespace ) { const flagToken = tokens[tokens.length - 1]; const flagTokenStart = remainderIndex - flagToken.length; @@ -287,7 +289,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: true, booleanFlagName, - separatorMode: hasTrailingSpace(input, flagTokenStart) + separatorMode: hasWhitespaceBefore(input, flagTokenStart) ? "optional" : undefined, directionSensitive: true, @@ -297,19 +299,19 @@ function resolveCompletionTarget( // ── Spec case 3b: last token committed, complete next ─────── // startIndex is the raw position — includes any trailing // whitespace that the user typed. When trailing whitespace is - // present, separatorMode becomes "optional" because the space - // is already consumed. + // present, separatorMode becomes "optional" because the + // whitespace is already consumed. if (pendingFlag !== undefined) { // Flag awaiting a value — either the user moved forward or - // trailing space committed the flag (direction doesn't matter). + // trailing whitespace committed the flag (direction doesn't matter). return { completionNames: [pendingFlag], startIndex: remainderIndex, isPartialValue: false, includeFlags: false, booleanFlagName: undefined, - separatorMode: trailingSpace ? "optional" : undefined, - directionSensitive: !trailingSpace, + separatorMode: trailingWhitespace ? "optional" : undefined, + directionSensitive: !trailingWhitespace, }; } return { @@ -318,7 +320,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: true, booleanFlagName, - separatorMode: trailingSpace ? "optional" : undefined, + separatorMode: trailingWhitespace ? "optional" : undefined, directionSensitive: false, }; } @@ -355,9 +357,9 @@ function resolveCompletionTarget( // b. Otherwise — the last token is complete (direction="forward", // fully quoted, or trailing whitespace). Return startIndex // at the *end* of the consumed input (including any trailing -// space) and offer completions for the next parameters. When -// trailing whitespace is present, separatorMode is "optional" -// because the space is already consumed. +// whitespace) and offer completions for the next parameters. +// When trailing whitespace is present, separatorMode is +// "optional" because the whitespace is already consumed. // // ── Exceptions to case 3a ──────────────────────────────────────────────── // @@ -589,7 +591,7 @@ async function completeDescriptor( // the input is valid but could mean either "stay at this level" or // "advance to the next level". For free-form parameter values, // the input's trailing whitespace is used instead (no ambiguity to -// resolve; trailing space means the token is complete). +// resolve; trailing whitespace means the token is complete). // // Always returns a result — every input has a longest valid prefix // (at minimum the empty string, startIndex=0). An empty completions @@ -668,7 +670,7 @@ export async function getCommandCompletion( const completions: CompletionGroup[] = []; let separatorMode: SeparatorMode | undefined = result.suffix.length === 0 && - hasTrailingSpace(input, commandConsumedLength) + hasWhitespaceBefore(input, commandConsumedLength) ? "optional" : undefined; let closedSet = true; @@ -690,14 +692,14 @@ export async function getCommandCompletion( // the user never typed them. Detect this by checking whether // the normalized command ends with whitespace, which indicates // the resolver already considers the last token committed. - const normalizedCommitted = /\s$/.test(partialCommand); + const implicitlyCommitted = /\s$/.test(partialCommand); // Direction matters at the command level when the command is // exactly matched and could be either "committed to" (forward) // or "reconsidered" (backward). const directionSensitiveCommand = descriptor !== undefined && result.matched && - !normalizedCommitted && + !implicitlyCommitted && result.suffix === "" && table !== undefined; const reconsideringCommand = diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index 6297509776..4c53308be7 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -78,7 +78,7 @@ function getCompletionNamespaceKeys(context: CommandHandlerContext): string[] { export async function requestCompletion( requestPrefix: string, context: CommandHandlerContext, - direction?: CompletionDirection, + direction?: CompletionDirection, // defaults to forward-like behavior when omitted ): Promise { debugCompletion(`Request completion for prefix: '${requestPrefix}'`); const namespaceKeys = getCompletionNamespaceKeys(context); diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index f64ac046ce..8c3745ca08 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -1503,7 +1503,7 @@ describe("Command Completion - startIndex", () => { it("does not back up with trailing space '@comptest run ' backward", async () => { // Trailing space means the user already committed "run", - // so backward doesn't trigger uncommittedCommand; parameter + // so backward doesn't trigger reconsideringCommand; parameter // completions are offered instead. const result = await getCommandCompletion( "@comptest run ", @@ -1601,7 +1601,7 @@ describe("Command Completion - startIndex", () => { it("empty input backward does not backtrack", async () => { // Empty input with backward shouldn't crash; normalizeCommand - // generates implicit tokens that are "normalizedCommitted". + // generates implicit tokens that are implicitly committed. const result = await getCommandCompletion("", "backward", context); expect(result).toBeDefined(); expect(result.completions.length).toBeGreaterThan(0); diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index a2ca0677e8..b5de9f285b 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -73,6 +73,7 @@ export type CommandResult = { tokenUsage?: CompletionUsageStats; }; +// Architecture: docs/architecture/completion.md — Data flow / Key types export type CommandCompletionResult = { // Index into the input where the resolved prefix ends and the // filter/completion region begins. input[0..startIndex) is fully diff --git a/ts/packages/shell/src/renderer/src/partial.ts b/ts/packages/shell/src/renderer/src/partial.ts index c26c579f9d..43b1ce405b 100644 --- a/ts/packages/shell/src/renderer/src/partial.ts +++ b/ts/packages/shell/src/renderer/src/partial.ts @@ -49,6 +49,7 @@ function getLeafNode(node: Node, offset: number) { return undefined; } +// Architecture: docs/architecture/completion.md — §6 Shell — DOM Adapter export class PartialCompletion { private readonly searchMenu: SearchMenu; private readonly session: PartialCompletionSession; diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 7ab1604ab6..15d3a6a6c1 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -65,6 +65,7 @@ export interface ICompletionDispatcher { // from the raw prefix before being passed to the menu, so the trie // still matches. // +// Architecture: docs/architecture/completion.md — §5 Shell — Completion Session // 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 diff --git a/ts/packages/shell/src/renderer/src/search.ts b/ts/packages/shell/src/renderer/src/search.ts index 91e02f50cb..43c7511e9c 100644 --- a/ts/packages/shell/src/renderer/src/search.ts +++ b/ts/packages/shell/src/renderer/src/search.ts @@ -10,6 +10,7 @@ import { SearchMenuUI, } from "./searchMenuUI/searchMenuUI"; +// Architecture: docs/architecture/completion.md — §7 Shell — Search Menu export class SearchMenu extends SearchMenuBase { private searchMenuUI: SearchMenuUI | undefined; constructor( diff --git a/ts/packages/shell/src/renderer/src/searchMenuBase.ts b/ts/packages/shell/src/renderer/src/searchMenuBase.ts index 82060bff72..43e90ee58f 100644 --- a/ts/packages/shell/src/renderer/src/searchMenuBase.ts +++ b/ts/packages/shell/src/renderer/src/searchMenuBase.ts @@ -16,6 +16,7 @@ export function normalizeMatchText(text: string): string { .toLowerCase(); } +// Architecture: docs/architecture/completion.md — §7 Shell — Search Menu export class SearchMenuBase { private trie: TST = new TST(); private prefix: string | undefined; From cd7ad080dcb7bda7c0bfda75a03400d4060a87ef Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 17 Mar 2026 21:20:52 -0700 Subject: [PATCH 6/7] Add CompletionDirection test coverage - Grammar: optional ()?, repeat ()+/()*, spacing=none, minPrefixLength backward - Cache: conflicting directionSensitive across constructions - Dispatcher: graceful fallback, catch block with throwing agent, flat agent backward - Shell: direction change while PENDING, direction detection patterns, spacePunctuation + direction --- ...rammarCompletionDirectionSensitive.spec.ts | 216 +++++++++++++++++ .../grammarCompletionPrefixLength.spec.ts | 27 +++ ts/packages/cache/test/completion.spec.ts | 97 ++++++++ .../dispatcher/test/completion.spec.ts | 141 ++++++++++- .../test/partialCompletion/direction.spec.ts | 225 ++++++++++++++++++ .../partialCompletion/separatorMode.spec.ts | 49 ++++ 6 files changed, 753 insertions(+), 2 deletions(-) diff --git a/ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts index 4f173dea9a..948cc8e8f6 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionDirectionSensitive.spec.ts @@ -237,6 +237,222 @@ describe("Grammar Completion - directionSensitive", () => { }); }); + describe("optional part ()?", () => { + const g = ` = play (shuffle)? music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("sensitive for 'play' (optional not yet consumed)", () => { + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for 'play ' with trailing space", () => { + const result = matchGrammarCompletion( + grammar, + "play ", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive for 'play shuffle' (optional consumed, no trailing space)", () => { + const result = matchGrammarCompletion( + grammar, + "play shuffle", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("backward on 'play shuffle' backs up to 'shuffle'", () => { + const result = matchGrammarCompletion( + grammar, + "play shuffle", + undefined, + "backward", + ); + // Backward should back up to offer "shuffle" instead of + // the next word "music". + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for 'play shuffle ' (trailing space commits)", () => { + const result = matchGrammarCompletion( + grammar, + "play shuffle ", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + + it("sensitive for exact match 'play shuffle music'", () => { + const result = matchGrammarCompletion( + grammar, + "play shuffle music", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("sensitive for exact match (optional skipped) 'play music'", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + }); + + describe("repeat part ()+", () => { + const g = ` = play (song)+ now -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("sensitive for 'play song' (one iteration matched)", () => { + const result = matchGrammarCompletion( + grammar, + "play song", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("backward on 'play song' backs up", () => { + const result = matchGrammarCompletion( + grammar, + "play song", + undefined, + "backward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("sensitive for 'play song song' (two iterations matched)", () => { + const result = matchGrammarCompletion( + grammar, + "play song song", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("not sensitive for 'play song ' (trailing space commits)", () => { + const result = matchGrammarCompletion( + grammar, + "play song ", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(false); + }); + }); + + describe("repeat part ()*", () => { + const g = ` = play (song)* now -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("sensitive for 'play' (zero iterations, but 'play' matched)", () => { + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("sensitive for 'play song' (one iteration matched)", () => { + const result = matchGrammarCompletion( + grammar, + "play song", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + }); + + describe("spacing=none with backward", () => { + const g = ` [spacing=none] = play music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("sensitive for 'play' (couldBackUp always true in none mode)", () => { + // In none mode, whitespace is not a separator, so + // couldBackUp is always true when words match. + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("backward on 'play' backs up to offer 'play'", () => { + const result = matchGrammarCompletion( + grammar, + "play", + undefined, + "backward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("sensitive for 'playmusic' (exact match, none mode)", () => { + const result = matchGrammarCompletion( + grammar, + "playmusic", + undefined, + "forward", + ); + expect(result.directionSensitive).toBe(true); + }); + + it("backward on 'playmusic' backs up to 'music'", () => { + const result = matchGrammarCompletion( + grammar, + "playmusic", + undefined, + "backward", + ); + expect(result.directionSensitive).toBe(true); + }); + }); + + describe("minPrefixLength with backward direction", () => { + const g = [ + ` = play music -> true;`, + ` = stop music -> true;`, + ].join("\n"); + const grammar = loadGrammarRules("test.grammar", g); + + it("backward respects minPrefixLength", () => { + const result = matchGrammarCompletion( + grammar, + "play music", + 5, + "backward", + ); + // minPrefixLength=5 means only completions at position ≥5 + // are relevant. Backward on "play music" would back up + // to "music" at position 4 (or 5 with space), which should + // still be valid since 5 ≥ 5. + expect(result.directionSensitive).toBe(true); + }); + }); + describe("forward and backward produce same directionSensitive", () => { const g = ` = play music -> true;`; const grammar = loadGrammarRules("test.grammar", g); diff --git a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts index fa7eb7c382..1aee82dea6 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts @@ -1144,4 +1144,31 @@ describe("Grammar Completion - matchedPrefixLength", () => { }); }); }); + + describe("optional part ()? with backward", () => { + const g = ` = play (shuffle)? music -> true;`; + const grammar = loadGrammarRules("test.grammar", g); + + it("backward on 'play shuffle' backs up to 'shuffle'", () => { + const result = matchGrammarCompletion( + grammar, + "play shuffle", + undefined, + "backward", + ); + expect(result.completions).toContain("shuffle"); + expect(result.matchedPrefixLength).toBe(4); + }); + + it("forward on 'play shuffle' offers 'music'", () => { + const result = matchGrammarCompletion( + grammar, + "play shuffle", + undefined, + "forward", + ); + expect(result.completions).toEqual(["music"]); + expect(result.matchedPrefixLength).toBe(12); + }); + }); }); diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index 50bca523c4..95890c4e12 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -1272,5 +1272,102 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); expect(result!.directionSensitive).toBe(true); }); + + it("multiple constructions: sensitive wins over non-sensitive at same prefix length", () => { + // c1 has two parts — "play song" at position 4 is + // direction-sensitive (has parts to back up to). + const c1 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + // c2 is a single-part construction that only matches + // at prefix length 0. It won't compete at length 4. + const c2 = Construction.create( + [createMatchPart(["stop"], "verb")], + new Map(), + ); + const cache = makeCache([c1, c2]); + const result = cache.completion("play", defaultOptions, "forward"); + expect(result).toBeDefined(); + // c1 matches at 4 > c2's 0 — c1 wins, which IS sensitive. + expect(result!.directionSensitive).toBe(true); + }); + + it("multiple constructions at same prefix length: any sensitive makes result sensitive", () => { + // Two constructions that both match "play" at prefix 4. + // One continues to "song", the other to "video". + // Both have matched parts → both flag directionSensitive. + const c1 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const c2 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["video"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c1, c2]); + const result = cache.completion("play", defaultOptions, "forward"); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(true); + }); + + it("longer match resets directionSensitive from shorter sensitive match", () => { + // c1 matches "play" at 4 (sensitive) but c2 matches + // "play song" at 9 (also sensitive). When maxPrefixLength + // advances from 4→9, directionSensitive is reset and + // re-evaluated at the longer match. + const c1 = Construction.create( + [createMatchPart(["play"], "verb")], + new Map(), + ); + const c2 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + createMatchPart(["now"], "adv"), + ], + new Map(), + ); + const cache = makeCache([c1, c2]); + const result = cache.completion( + "play song", + defaultOptions, + "forward", + ); + expect(result).toBeDefined(); + // c2 dominates at prefix length 9. + expect(result!.directionSensitive).toBe(true); + expect(result!.completions).toContain("now"); + }); + + it("not sensitive when trailing space commits across all constructions", () => { + const c1 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["song"], "noun"), + ], + new Map(), + ); + const c2 = Construction.create( + [ + createMatchPart(["play"], "verb"), + createMatchPart(["video"], "noun"), + ], + new Map(), + ); + const cache = makeCache([c1, c2]); + const result = cache.completion("play ", defaultOptions, "forward"); + expect(result).toBeDefined(); + expect(result!.directionSensitive).toBe(false); + }); }); }); diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index 8c3745ca08..b12af3cf59 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -446,13 +446,53 @@ const numstrAgent: AppAgent = { ...getCommandInterface(numstrHandlers), }; +// --------------------------------------------------------------------------- +// Throwing agent — getCommandCompletion always throws +// --------------------------------------------------------------------------- +const throwHandlers = { + description: "Agent whose completion throws", + defaultSubCommand: "boom", + commands: { + boom: { + description: "Throws on completion", + parameters: { + args: { + value: { + description: "A value", + }, + }, + }, + run: async () => {}, + getCompletion: async (): Promise => { + throw new Error("agent completion exploded"); + }, + }, + }, +} as const; + +const throwConfig: AppAgentManifest = { + emojiChar: "💥", + description: "Throwing completion test", +}; + +const throwAgent: AppAgent = { + ...getCommandInterface(throwHandlers), +}; + const testCompletionAgentProviderMulti: AppAgentProvider = { - getAppAgentNames: () => ["comptest", "flattest", "nocmdtest", "numstrtest"], + getAppAgentNames: () => [ + "comptest", + "flattest", + "nocmdtest", + "numstrtest", + "throwtest", + ], getAppAgentManifest: async (name: string) => { if (name === "comptest") return config; if (name === "flattest") return flatConfig; if (name === "nocmdtest") return noCommandsConfig; if (name === "numstrtest") return numstrConfig; + if (name === "throwtest") return throwConfig; throw new Error(`Unknown: ${name}`); }, loadAppAgent: async (name: string) => { @@ -460,10 +500,19 @@ const testCompletionAgentProviderMulti: AppAgentProvider = { if (name === "flattest") return flatAgent; if (name === "nocmdtest") return noCommandsAgent; if (name === "numstrtest") return numstrAgent; + if (name === "throwtest") return throwAgent; throw new Error(`Unknown: ${name}`); }, unloadAppAgent: async (name: string) => { - if (!["comptest", "flattest", "nocmdtest", "numstrtest"].includes(name)) + if ( + ![ + "comptest", + "flattest", + "nocmdtest", + "numstrtest", + "throwtest", + ].includes(name) + ) throw new Error(`Unknown: ${name}`); }, }; @@ -1679,4 +1728,92 @@ describe("Command Completion - startIndex", () => { expect(result.directionSensitive).toBeFalsy(); }); }); + + describe("graceful fallback", () => { + it("backward with unknown agent falls back to system completions", async () => { + const result = await getCommandCompletion( + "@nonexistent cmd", + "backward", + context, + ); + expect(result).toBeDefined(); + expect(result.startIndex).toBeGreaterThanOrEqual(0); + // Should fall back to system agent completions. + expect(result.completions.length).toBeGreaterThan(0); + expect(result.directionSensitive).toBeDefined(); + }); + }); + + describe("catch block — agent completion throws", () => { + it("returns safe default when agent getCommandCompletion throws", async () => { + // throwtest's getCompletion always throws. + // The catch block in getCommandCompletion should return + // { startIndex: 0, completions: [], closedSet: false, + // directionSensitive: false }. + const result = await getCommandCompletion( + "@throwtest boom val", + "forward", + context, + ); + expect(result.startIndex).toBe(0); + expect(result.completions).toHaveLength(0); + expect(result.closedSet).toBe(false); + expect(result.directionSensitive).toBe(false); + }); + + it("returns safe default for throwing agent with backward direction", async () => { + const result = await getCommandCompletion( + "@throwtest boom val", + "backward", + context, + ); + expect(result.startIndex).toBe(0); + expect(result.completions).toHaveLength(0); + expect(result.closedSet).toBe(false); + expect(result.directionSensitive).toBe(false); + }); + }); + + describe("backward direction — flat agent (no subcommand table)", () => { + it("backward on '@flattest' with no trailing space", async () => { + const result = await getCommandCompletion( + "@flattest", + "backward", + context, + ); + // "@flattest" — agent recognized but no subcommand table. + // Backward shouldn't crash even though there are no + // subcommands to reconsider. + expect(result).toBeDefined(); + expect(result.startIndex).toBeGreaterThanOrEqual(0); + expect(result.completions).toBeDefined(); + expect(result.closedSet).toBeDefined(); + expect(result.directionSensitive).toBeDefined(); + }); + + it("backward on '@flattest --release' backs up to flag alternatives", async () => { + const result = await getCommandCompletion( + "@flattest --release", + "backward", + context, + ); + // "--release" is a boolean flag — fully consumed, so + // backward does not trigger flag-backtrack. + expect(result).toBeDefined(); + expect(result.startIndex).toBeGreaterThanOrEqual(0); + // Boolean flag is consumed — no pending value to reconsider. + expect(result.directionSensitive).toBeFalsy(); + }); + + it("directionSensitive is false for '@flattest ' with trailing space", async () => { + const result = await getCommandCompletion( + "@flattest ", + "forward", + context, + ); + // Trailing space commits — no direction-sensitive boundary. + // Flat agents have no subcommand to reconsider. + expect(result.directionSensitive).toBeFalsy(); + }); + }); }); diff --git a/ts/packages/shell/test/partialCompletion/direction.spec.ts b/ts/packages/shell/test/partialCompletion/direction.spec.ts index f7a4c31b8f..bbc1f093bb 100644 --- a/ts/packages/shell/test/partialCompletion/direction.spec.ts +++ b/ts/packages/shell/test/partialCompletion/direction.spec.ts @@ -1,8 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { jest } from "@jest/globals"; import { PartialCompletionSession, + ICompletionDispatcher, + CommandCompletionResult, makeMenu, makeDispatcher, makeCompletionResult, @@ -362,3 +365,225 @@ describe("PartialCompletionSession — committed-past-boundary re-fetch", () => expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); }); }); + +// ── direction change while PENDING ──────────────────────────────────────────── + +describe("PartialCompletionSession — direction change while PENDING", () => { + test("direction change while PENDING is suppressed (no 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, "forward"); + // Still PENDING — second update with different direction is suppressed + session.update("play", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("stale resolution after direction change is ignored", async () => { + const menu = makeMenu(); + let resolveFn!: (v: CommandCompletionResult) => void; + const pending = new Promise( + (resolve) => (resolveFn = resolve), + ); + const forwardResult = makeCompletionResult(["song"], 4, { + directionSensitive: true, + }); + const backwardResult = makeCompletionResult(["play"], 0, { + directionSensitive: true, + }); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockReturnValueOnce(pending) + .mockResolvedValue(backwardResult), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + // First update starts PENDING + session.update("play", getPos, "forward"); + + // Resolve stale promise (forward result) + resolveFn(forwardResult); + await Promise.resolve(); + + // Session processed the result — anchor is now "play" + // Now change direction — if at exact anchor + directionSensitive, re-fetch + session.update("play", getPos, "backward"); + + // Depending on A7: session was established with forwardResult, + // direction changes at exact anchor with directionSensitive=true → re-fetch + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play", + "backward", + ); + }); + + test("hide during PENDING then diverged input with new direction triggers new session", async () => { + const menu = makeMenu(); + let resolveFn!: (v: CommandCompletionResult) => void; + const pending = new Promise( + (resolve) => (resolveFn = resolve), + ); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockReturnValueOnce(pending) + .mockResolvedValue(makeCompletionResult(["song"], 4)), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos, "forward"); + + // Hide cancels the in-flight fetch + session.hide(); + + // Resolve the now-stale promise + resolveFn(makeCompletionResult(["song"], 4)); + await Promise.resolve(); + + // Diverged input with backward — anchor was "play", "stop" + // does not match → triggers a new session + session.update("stop", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "stop", + "backward", + ); + }); +}); + +// ── direction detection edge cases ──────────────────────────────────────────── +// +// These tests exercise direction sequences that match the detection logic +// in partial.ts: +// direction = input.length < previousInput.length && +// previousInput.startsWith(input) ? "backward" : "forward" +// +// They verify the session handles all edge cases correctly: +// - true backspace (strict prefix, shorter) +// - replacement (same length but different content → forward) +// - non-prefix change (different text → forward) + +describe("PartialCompletionSession — direction detection patterns", () => { + test("backspace produces backward direction: 'play' → 'pla'", async () => { + const menu = makeMenu(); + const result1 = makeCompletionResult(["song"], 4, { + directionSensitive: true, + }); + const result2 = makeCompletionResult(["play"], 0); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockResolvedValueOnce(result1) + .mockResolvedValue(result2), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos, "forward"); + await Promise.resolve(); + + // "pla" is a strict prefix of "play" and shorter → backward + session.update("pla", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "pla", + "backward", + ); + }); + + test("replacement is forward: 'abc' → 'abd' (same length, different content)", async () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + // "abc" starts a new session + session.update("abc", getPos, "forward"); + await Promise.resolve(); // → ACTIVE, anchor = "abc" + + // "abd" is not shorter than "abc" → forward by partial.ts logic. + // But "abd" doesn't start with anchor "abc" → diverged → re-fetch. + session.update("abd", getPos, "forward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "abd", + "forward", + ); + }); + + test("non-prefix change is forward: 'hello' → 'world'", async () => { + const menu = makeMenu(); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockResolvedValueOnce(makeCompletionResult(["next"], 5)) + .mockResolvedValue(makeCompletionResult(["next"], 5)), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("hello", getPos, "forward"); + await Promise.resolve(); + + // "world" is not a prefix continuation of "hello" → forward + session.update("world", getPos, "forward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "world", + "forward", + ); + }); + + test("empty to non-empty is forward", () => { + const menu = makeMenu(); + const dispatcher = makeDispatcher(); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos, "forward"); + session.update("p", getPos, "forward"); + + // Both calls should be forward + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + // First call fetches for "" + expect(dispatcher.getCommandCompletion).toHaveBeenCalledWith( + "", + "forward", + ); + }); + + test("non-empty to empty is backward", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + directionSensitive: true, + }); + const dispatcher: ICompletionDispatcher = { + getCommandCompletion: jest + .fn() + .mockResolvedValueOnce(result) + .mockResolvedValue(makeCompletionResult(["play"], 0)), + }; + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos, "forward"); + await Promise.resolve(); + + // Clearing input (all backspace) → "" is prefix of "play" and shorter + session.update("", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "", + "backward", + ); + }); +}); diff --git a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts index 2f7416913e..a287dcec91 100644 --- a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts +++ b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts @@ -192,6 +192,55 @@ describe("PartialCompletionSession — separatorMode: optional", () => { }); }); +// ── separatorMode + direction interactions ──────────────────────────────────── + +describe("PartialCompletionSession — separatorMode + direction", () => { + test("spacePunctuation with backward direction: punctuation separator commits", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music", "movie"], 4, { + separatorMode: "spacePunctuation", + directionSensitive: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos, "forward"); + await Promise.resolve(); // → ACTIVE, anchor = "play", deferred + + // Type punctuation separator: menu should appear with the completions + session.update("play.", getPos, "backward"); + + // Separator satisfies spacePunctuation — menu should show + expect(menu.updatePrefix).toHaveBeenCalled(); + // No re-fetch (separator typed after anchor, within same session) + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("spacePunctuation with backward direction re-fetches when directionSensitive at anchor", async () => { + const menu = makeMenu(); + // startIndex=4 = anchor length, so anchor = "play", input = "play" + // directionSensitive=true at exact anchor → A7 applies + const result = makeCompletionResult(["song", "track"], 4, { + separatorMode: "spacePunctuation", + directionSensitive: true, + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos, "forward"); + await Promise.resolve(); // → ACTIVE, anchor = "play" + + // Same input, backward direction, at exact anchor + sensitive → A7 + session.update("play", getPos, "backward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( + "play", + "backward", + ); + }); +}); + // ── separatorMode edge cases ───────────────────────────────────────────────── describe("PartialCompletionSession — separatorMode edge cases", () => { From 6e981ff56c9f59d34156baeebc712dbcb02dfce1 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 18 Mar 2026 07:55:57 -0700 Subject: [PATCH 7/7] Add openWildcard signal for sliding wildcard boundaries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add openWildcard boolean to the completion pipeline (grammar matcher → cache → dispatcher → shell) to handle ambiguous wildcard boundaries. When a grammar wildcard is finalized at end-of-input and the following keyword is offered as a completion, the wildcard extent is uncertain. Previously closedSet=true would permanently block re-fetching (CJK) or cause wasteful per-keystroke re-fetches (Latin). The shell now uses anchor sliding: instead of re-fetching or giving up, it slides the anchor forward on further input. The trie stays intact so the menu re-appears at each word boundary. Recovery is automatic via trigger B4 when the user types the keyword. Fix request/match/translate command handlers dropping openWildcard and directionSensitive from requestCompletion() results. --- ts/docs/architecture/completion.md | 41 +++++- .../actionGrammar/src/grammarMatcher.ts | 24 ++++ .../actionGrammar/src/nfaCompletion.ts | 7 +- .../grammarCompletionLongestMatch.spec.ts | 37 +++++ ts/packages/agentSdk/src/command.ts | 6 + ts/packages/cache/src/cache/grammarStore.ts | 6 + .../src/constructions/constructionCache.ts | 13 ++ .../dispatcher/src/command/completion.ts | 10 ++ .../handlers/matchCommandHandler.ts | 2 + .../handlers/requestCommandHandler.ts | 2 + .../handlers/translateCommandHandler.ts | 2 + .../src/translation/requestCompletion.ts | 3 + .../dispatcher/test/completion.spec.ts | 65 +++++++++ .../test/requestCompletionPropagation.spec.ts | 130 ++++++++++++++++++ .../dispatcher/types/src/dispatcher.ts | 6 + .../renderer/src/partialCompletionSession.ts | 36 +++++ .../shell/test/partialCompletion/helpers.ts | 2 + .../resultProcessing.spec.ts | 7 + .../startIndexSeparatorContract.spec.ts | 1 + .../stateTransitions.spec.ts | 108 +++++++++++++++ 20 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index 6156e12d0b..b9f0570e34 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -77,6 +77,7 @@ The return path carries `CommandCompletionResult`: separatorMode?: SeparatorMode; // "space" | "spacePunctuation" | "optional" | "none" closedSet: boolean; // true → list is exhaustive directionSensitive: boolean; // true → opposite direction would produce different results + openWildcard: boolean; // true → wildcard boundary is ambiguous; shell should slide anchor } ``` @@ -117,6 +118,10 @@ grammar rules. needed at the boundary (Latin vs CJK, `[spacing=none]` rules). - `closedSet` — `true` for pure keyword alternatives; `false` when property/wildcard completions are emitted (entity values are external). +- `openWildcard` — `true` when a keyword completion is offered after a + wildcard that was finalized at end-of-input (Category 2 where the + preceding wildcard consumed the entire remaining input). Signals that + the wildcard boundary is ambiguous. --- @@ -264,11 +269,11 @@ lifecycle of a completion interaction. | ---- | ------------------------------------------------ | ------------ | ------------------- | | A1 | No active session | Invalidation | Re-fetch | | A2 | Input no longer extends anchor | Invalidation | Re-fetch | -| A3 | Non-separator char typed when separator required | Invalidation | Re-fetch | +| A3 | Non-separator char typed when separator required | Invalidation | Re-fetch (or slide) | | A7 | Direction changed on direction-sensitive result | Invalidation | Re-fetch | | B4 | Unique match (always fires) | Navigation | Re-fetch next level | | B5 | Separator typed after exact match | Navigation | Re-fetch next level | -| C6 | No trie matches + open set | Discovery | Re-fetch | +| C6 | No trie matches + open set | Discovery | Re-fetch (or slide) | | — | Trie has matches | — | Reuse locally | | — | No matches + closed set | — | Reuse (menu hidden) | @@ -360,6 +365,38 @@ A boolean flowing through the entire pipeline: Merge rule: AND across sources (closed only if _all_ sources are closed). +### `openWildcard` + +A boolean flowing through the entire pipeline, signaling that the completions +are offered at a position where a wildcard was finalized at end-of-input. + +- **`true`** — the wildcard's extent is ambiguous (the user may still be + typing within it). The keyword following the wildcard (e.g. "by") is + offered as a completion, and `closedSet` correctly describes that keyword + set as exhaustive. However, the _position_ of that set is uncertain. + + The shell handles this with **anchor sliding**: instead of re-fetching + (which would return the same keyword at a shifted position) or giving up + (stuck when `closedSet=true`), the shell slides the anchor forward to the + current input. The trie and metadata stay intact, so the menu re-appears + at the next word boundary when the user types a separator. + + Recovery is automatic: when the user eventually types the keyword and it + uniquely matches in the trie (trigger B4), the session re-fetches for the + next grammar part. + +- **`false`** — no sliding wildcard boundary; normal `closedSet` semantics + apply. + +Merge rule: OR across sources (open wildcard if _any_ source has one). + +Affects triggers A3 and C6 in the re-fetch decision tree: + +- **A3** (non-separator after anchor): when `openWildcard=true`, the anchor + slides forward instead of triggering a re-fetch. +- **C6** (trie empty, closed set): when `openWildcard=true`, the anchor + slides forward instead of staying permanently hidden. + --- ## CLI integration diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 312148597c..24af14dc10 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -1176,6 +1176,12 @@ export type GrammarCompletionResult = { // direction. When false, the caller can skip re-fetching on // direction change. directionSensitive: boolean; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard: boolean; }; function getGrammarCompletionProperty( @@ -1412,6 +1418,13 @@ export function matchGrammarCompletion( // whenever maxPrefixLength advances (old candidates discarded). let directionSensitive = false; + // Whether a wildcard was finalized at end-of-input and the + // following keyword was offered as a completion. When true the + // wildcard boundary is ambiguous — the user may still be typing + // within the wildcard — so the caller should slide its anchor + // forward instead of re-fetching or giving up. + let openWildcard = false; + // Helper: update maxPrefixLength. When it increases, all previously // accumulated completions from shorter matches are irrelevant // — clear them. @@ -1423,6 +1436,7 @@ export function matchGrammarCompletion( separatorMode = undefined; closedSet = true; directionSensitive = false; + openWildcard = false; } } @@ -1626,6 +1640,15 @@ export function matchGrammarCompletion( if (hasPartToReconsider) { directionSensitive = true; } + // When a wildcard was finalized at end-of-input and we + // offered the following keyword as a completion, the + // wildcard boundary is ambiguous — the user may still be + // typing within the wildcard. Signal this so the shell + // can slide its anchor forward instead of re-fetching or + // giving up when the user keeps typing. + if (savedPendingWildcard?.valueId !== undefined) { + openWildcard = true; + } // 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 @@ -1714,6 +1737,7 @@ export function matchGrammarCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; debugCompletion(`Completed. ${JSON.stringify(result)}`); return result; diff --git a/ts/packages/actionGrammar/src/nfaCompletion.ts b/ts/packages/actionGrammar/src/nfaCompletion.ts index aaba8fc9a9..dcb16b1302 100644 --- a/ts/packages/actionGrammar/src/nfaCompletion.ts +++ b/ts/packages/actionGrammar/src/nfaCompletion.ts @@ -261,7 +261,11 @@ export function computeNFACompletions( if (reachableStates.length === 0) { debugCompletion(` → no reachable states, returning empty`); - return { completions: [], directionSensitive: false }; + return { + completions: [], + directionSensitive: false, + openWildcard: false, + }; } // Explore completions from reachable states @@ -286,6 +290,7 @@ export function computeNFACompletions( const result: GrammarCompletionResult = { completions: uniqueCompletions, directionSensitive: false, + openWildcard: false, }; const grammarProperties = buildGrammarProperties(nfa, properties); if (grammarProperties.length > 0) { diff --git a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts index 893a2bc9cc..0192bbe4da 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts @@ -493,6 +493,7 @@ describe("Grammar Completion - longest match property", () => { const result = matchGrammarCompletion(grammar, ""); expect(result.completions).toContain("play"); expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(false); }); it("closedSet=true for alternatives after prefix", () => { @@ -501,12 +502,14 @@ describe("Grammar Completion - longest match property", () => { expect(result.completions).toContain("pop"); expect(result.completions).toContain("jazz"); expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(false); }); it("closedSet=true for exact match (no completions)", () => { const result = matchGrammarCompletion(grammar, "play rock"); expect(result.completions).toHaveLength(0); expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(false); }); }); @@ -545,12 +548,46 @@ describe("Grammar Completion - longest match property", () => { expect(result.properties).toBeDefined(); expect(result.properties!.length).toBeGreaterThan(0); expect(result.closedSet).toBe(false); + expect(result.openWildcard).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); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard=true for multi-word wildcard before keyword", () => { + const result = matchGrammarCompletion( + grammar, + "play my favorite song", + ); + expect(result.completions).toContain("by"); + expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard=true for ambiguous keyword boundary", () => { + // "play hello by" is ambiguous: "by" could be part of + // the track name or the keyword delimiter. The grammar + // produces both interpretations at prefix length 13, + // so openWildcard stays true. + const result = matchGrammarCompletion(grammar, "play hello by"); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard=true persists with trailing separator", () => { + // "play hello by " — still ambiguous: "by " could be + // part of the track name in one interpretation. + // openWildcard stays true because the grammar considers + // both parse paths. The shell resolves this via B4 + // (unique match) when the user types "by" in the trie. + const result = matchGrammarCompletion( + grammar, + "play hello by ", + ); + expect(result.openWildcard).toBe(true); }); }); diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 0ffbb219b2..cbacd1d024 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -112,6 +112,12 @@ export type CompletionGroups = { // direction change. When omitted, the dispatcher will conservatively // assume true if matchedPrefixLength > 0 and false otherwise. directionSensitive?: boolean | undefined; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard?: boolean | undefined; }; export interface AppAgentCommandInterface { diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index de2b04f194..d70eba0825 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -280,6 +280,7 @@ export class GrammarStoreImpl implements GrammarStore { let separatorMode: SeparatorMode | undefined; let closedSet: boolean | undefined; let directionSensitive: boolean = false; + let openWildcard: boolean = false; const filter = new Set(namespaceKeys); for (const [name, entry] of this.grammars) { if (filter && !filter.has(name)) { @@ -367,6 +368,7 @@ export class GrammarStoreImpl implements GrammarStore { separatorMode = undefined; closedSet = undefined; directionSensitive = false; + openWildcard = false; } if (partialPrefixLength === matchedPrefixLength) { completions.push(...partial.completions); @@ -388,6 +390,9 @@ export class GrammarStoreImpl implements GrammarStore { // length is direction-sensitive. directionSensitive = directionSensitive || partial.directionSensitive; + // True if any grammar result at this prefix + // length has an open wildcard. + openWildcard = openWildcard || partial.openWildcard; if ( partial.properties !== undefined && partial.properties.length > 0 @@ -418,6 +423,7 @@ export class GrammarStoreImpl implements GrammarStore { separatorMode, closedSet, directionSensitive, + openWildcard, }; } } diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 2a9f8a7938..63031c8946 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -95,6 +95,12 @@ export type CompletionResult = { // direction. When false, the caller can skip re-fetching on // direction change. directionSensitive?: boolean | undefined; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard?: boolean | undefined; }; // Architecture: docs/architecture/completion.md — §2 Cache Layer @@ -149,6 +155,13 @@ export function mergeCompletionResults( ? (first.directionSensitive ?? false) || (second.directionSensitive ?? false) : undefined, + // Open wildcard if either source has one. + openWildcard: + first.openWildcard !== undefined || + second.openWildcard !== undefined + ? (first.openWildcard ?? false) || + (second.openWildcard ?? false) + : undefined, }; } diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index b355e6021d..a23f28c959 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -441,6 +441,7 @@ async function getCommandParameterCompletion( let agentClosedSet: boolean | undefined; let separatorMode: SeparatorMode | undefined = target.separatorMode; let directionSensitive = false; + let openWildcard = false; const agent = context.agents.getAppAgent(result.actualAppAgentName); if (agent.getCommandCompletion && target.completionNames.length > 0) { @@ -480,6 +481,7 @@ async function getCommandParameterCompletion( directionSensitive = agentResult.directionSensitive ?? (groupPrefixLength !== undefined && groupPrefixLength > 0); + openWildcard = agentResult.openWildcard ?? false; debug( `Command completion parameter with agent: groupPrefixLength=${groupPrefixLength}, startIndex=${startIndex}`, ); @@ -496,6 +498,7 @@ async function getCommandParameterCompletion( params.nextArgs.length > 0, ), directionSensitive: target.directionSensitive || directionSensitive, + openWildcard, }; } @@ -514,6 +517,7 @@ async function completeDescriptor( separatorMode: SeparatorMode | undefined; closedSet: boolean; directionSensitive: boolean; + openWildcard: boolean; }> { const completions: CompletionGroup[] = []; let separatorMode: SeparatorMode | undefined; @@ -555,6 +559,7 @@ async function completeDescriptor( separatorMode, closedSet: true, directionSensitive: false, + openWildcard: false, }; } @@ -568,6 +573,7 @@ async function completeDescriptor( ), closedSet: parameterCompletions.closedSet, directionSensitive: parameterCompletions.directionSensitive, + openWildcard: parameterCompletions.openWildcard, }; } @@ -677,6 +683,7 @@ export async function getCommandCompletion( // Track whether direction influenced the result. When false, // the caller can skip re-fetching on direction change. let directionSensitive = false; + let openWildcard = false; const descriptor = result.descriptor; @@ -738,6 +745,7 @@ export async function getCommandCompletion( // direction) or if the agent/parameter level is. directionSensitive = directionSensitiveCommand || desc.directionSensitive; + openWildcard = desc.openWildcard; } else if (table !== undefined) { // descriptor is undefined: the suffix didn't resolve to any // known command or subcommand. startIndex already points to @@ -803,6 +811,7 @@ export async function getCommandCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; debug(`Command completion result:`, completionResult); @@ -817,6 +826,7 @@ export async function getCommandCompletion( separatorMode: undefined, closedSet: false, directionSensitive: false, + openWildcard: false, }; } } 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 a31cce7caf..62d637e336 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts @@ -67,6 +67,8 @@ export class MatchCommandHandler implements CommandHandler { result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; + result.directionSensitive = requestResult.directionSensitive; + result.openWildcard = requestResult.openWildcard; } } 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 083278dbaa..109e4a3451 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts @@ -490,6 +490,8 @@ export class RequestCommandHandler implements CommandHandler { result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; + result.directionSensitive = requestResult.directionSensitive; + result.openWildcard = requestResult.openWildcard; } } 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 85006f33e1..fec9d23c0e 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts @@ -92,6 +92,8 @@ export class TranslateCommandHandler implements CommandHandler { result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; + result.directionSensitive = requestResult.directionSensitive; + result.openWildcard = requestResult.openWildcard; } } return result; diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index 4c53308be7..b5b6fd8641 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -105,6 +105,7 @@ export async function requestCompletion( const separatorMode = results.separatorMode; const closedSet = results.closedSet; const directionSensitive = results.directionSensitive; + const openWildcard = results.openWildcard; const completions: CompletionGroup[] = []; if (results.completions.length > 0) { completions.push({ @@ -122,6 +123,7 @@ export async function requestCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; } @@ -148,6 +150,7 @@ export async function requestCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; } diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index b12af3cf59..21587b1220 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -341,6 +341,39 @@ const handlers = { }; }, }, + wildcard: { + description: "Simulates grammar open-wildcard result", + parameters: { + args: { + request: { + description: "A request with wildcard", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("request")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Keywords", + completions: ["by", "from"], + }, + ], + matchedPrefixLength: 5, + separatorMode: "spacePunctuation", + closedSet: true, + directionSensitive: true, + openWildcard: true, + }; + }, + }, }, } as const; @@ -1816,4 +1849,36 @@ describe("Command Completion - startIndex", () => { expect(result.directionSensitive).toBeFalsy(); }); }); + + describe("openWildcard propagation", () => { + it("propagates openWildcard=true from agent result", async () => { + const result = await getCommandCompletion( + "@comptest wildcard hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard defaults to false when agent does not set it", async () => { + const result = await getCommandCompletion( + "@comptest run ", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + + it("openWildcard is false for commands with no agent completion", async () => { + const result = await getCommandCompletion( + "@comptest noop", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + }); }); diff --git a/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts b/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts new file mode 100644 index 0000000000..0d96f9f34d --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + type CommandHandlerContext, + closeCommandHandlerContext, + initializeCommandHandlerContext, +} from "../src/context/commandHandlerContext.js"; +import { getCommandCompletion } from "../src/command/completion.js"; + +// --------------------------------------------------------------------------- +// Test: openWildcard and directionSensitive propagation through the +// request handler path (bare text → requestCommandHandler.getCompletion +// → requestCompletion → agentCache.completion) +// +// Strategy: initialize a context, then monkey-patch agentCache.completion +// to return a controlled CompletionResult with openWildcard and +// directionSensitive. Send bare text (no @-prefix) so the dispatcher +// routes through the request handler. +// --------------------------------------------------------------------------- + +describe("Request handler completion propagation", () => { + let context: CommandHandlerContext; + + beforeAll(async () => { + context = await initializeCommandHandlerContext("test", { + agents: { + actions: false, + schemas: false, + }, + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + }); + }); + afterAll(async () => { + if (context) { + await closeCommandHandlerContext(context); + } + }); + + function patchCacheCompletion(result: unknown): void { + (context.agentCache as any).completion = () => result; + } + + afterEach(() => { + // Restore original (disabled cache returns undefined) + patchCacheCompletion(undefined); + }); + + it("propagates openWildcard=true from cache through request handler", async () => { + patchCacheCompletion({ + completions: ["by", "from"], + matchedPrefixLength: 10, + separatorMode: "spacePunctuation", + closedSet: true, + directionSensitive: true, + openWildcard: true, + }); + + const result = await getCommandCompletion( + "play hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(true); + expect(result.directionSensitive).toBe(true); + expect(result.closedSet).toBe(true); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("propagates openWildcard=false from cache through request handler", async () => { + patchCacheCompletion({ + completions: ["music"], + matchedPrefixLength: 5, + separatorMode: "spacePunctuation", + closedSet: true, + directionSensitive: false, + openWildcard: false, + }); + + const result = await getCommandCompletion("play ", "forward", context); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + + it("returns openWildcard=false when cache returns undefined", async () => { + patchCacheCompletion(undefined); + + const result = await getCommandCompletion( + "play hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + + it("propagates directionSensitive through request handler", async () => { + patchCacheCompletion({ + completions: ["by"], + matchedPrefixLength: 5, + closedSet: false, + directionSensitive: true, + openWildcard: false, + }); + + const result = await getCommandCompletion("play ", "forward", context); + expect(result).toBeDefined(); + expect(result.directionSensitive).toBe(true); + }); + + it("propagates closedSet from cache through request handler", async () => { + patchCacheCompletion({ + completions: ["by", "from"], + matchedPrefixLength: 10, + closedSet: true, + openWildcard: true, + }); + + const result = await getCommandCompletion( + "play hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.closedSet).toBe(true); + }); +}); diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index 4652e334f4..0b3fc3c260 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -94,6 +94,12 @@ export type CommandCompletionResult = { // direction. When false, the caller can skip re-fetching on // direction change. directionSensitive: boolean; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard: boolean; }; export type AppAgentStatus = { diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 15d3a6a6c1..79943d6188 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -78,6 +78,11 @@ export class PartialCompletionSession { private closedSet: boolean = false; // True when completions differ between forward and backward. private directionSensitive: boolean = false; + // True when the completions are offered at a sliding wildcard + // boundary. When set, the shell slides the anchor forward on + // further input instead of re-fetching or giving up, and + // re-shows the menu at every word boundary. + private openWildcard: boolean = false; // Direction used for the last fetch. private lastDirection: CompletionDirection = "forward"; @@ -283,6 +288,22 @@ export class PartialCompletionSession { // the completion *entries* are exhaustive, not whether // the anchor token can extend. The grammar may parse // the longer input on a completely different path. + // + // However, when openWildcard is set, the anchor sits at + // a sliding wildcard boundary — the user is still typing + // within the wildcard, and re-fetching would produce the + // same result at a shifted position. Instead, slide the + // anchor forward to the current input: the trie and + // metadata stay intact, so the menu will re-appear at + // the next word boundary when the user types a separator. + if (this.openWildcard) { + debug( + `Partial completion anchor slide (A3): '${anchor}' → '${input}' (openWildcard)`, + ); + this.anchor = input; + this.menu.hide(); + return true; + } debug( `Partial completion re-fetch: non-separator after anchor (mode='${sepMode}', rawPrefix='${rawPrefix}')`, ); @@ -346,7 +367,21 @@ export class PartialCompletionSession { // 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). + // + // Special case: when openWildcard is set and the trie is empty, + // the user is still typing within the wildcard. Slide the anchor + // forward instead of re-fetching (wasteful, same result) or + // giving up (stuck). The trie stays intact so the menu will + // re-appear at the next word boundary. const active = this.menu.isActive(); + if (!active && this.openWildcard) { + debug( + `Partial completion anchor slide (C6): '${anchor}' → '${input}' (openWildcard)`, + ); + this.anchor = input; + this.menu.hide(); + return true; + } const reuse = closedSet || active; debug( `Partial completion ${reuse ? "reuse" : "re-fetch"}: closedSet=${closedSet}, menuActive=${active}`, @@ -384,6 +419,7 @@ export class PartialCompletionSession { this.separatorMode = result.separatorMode ?? "space"; this.closedSet = result.closedSet; this.directionSensitive = result.directionSensitive; + this.openWildcard = result.openWildcard; this.lastDirection = direction; const completions = toMenuItems(result.completions); diff --git a/ts/packages/shell/test/partialCompletion/helpers.ts b/ts/packages/shell/test/partialCompletion/helpers.ts index 5a1800dc9d..4fd9811a5a 100644 --- a/ts/packages/shell/test/partialCompletion/helpers.ts +++ b/ts/packages/shell/test/partialCompletion/helpers.ts @@ -65,6 +65,7 @@ export function makeDispatcher( separatorMode: undefined, closedSet: true, directionSensitive: false, + openWildcard: false, }, ): MockDispatcher { return { @@ -88,6 +89,7 @@ export function makeCompletionResult( completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, ...opts, }; } diff --git a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts index 28c4cb85fc..e8c429d7b3 100644 --- a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts +++ b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts @@ -45,6 +45,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group1, group2], closedSet: false, directionSensitive: false, + openWildcard: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -77,6 +78,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -106,6 +108,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -127,6 +130,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [{ name: "empty", completions: [] }], closedSet: false, directionSensitive: false, + openWildcard: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -152,6 +156,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -186,6 +191,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -216,6 +222,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [sortedGroup, unsortedGroup], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); diff --git a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts index 0ab79a49e6..723a162ef4 100644 --- a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts +++ b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts @@ -68,6 +68,7 @@ function makeSequentialDispatcher( completions: [], closedSet: true, directionSensitive: false, + openWildcard: false, }); return { getCommandCompletion: fn }; } diff --git a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts index b3173c4b9a..6246d7f198 100644 --- a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts +++ b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts @@ -317,3 +317,111 @@ describe("PartialCompletionSession — state transitions", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); }); }); + +describe("PartialCompletionSession — openWildcard anchor sliding", () => { + test("openWildcard=true: non-separator after anchor slides anchor instead of re-fetching", async () => { + const menu = makeMenu(); + // Grammar: play $(track) by $(artist) + // User typed "play my fav" → grammar returns "by" at position 11 + const result = makeCompletionResult(["by"], 11, { + closedSet: true, + openWildcard: true, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play my fav", getPos); + await Promise.resolve(); // → ACTIVE, anchor="play my fav" + + // User keeps typing the track name — non-separator char "o" + // Without openWildcard, this would trigger A3 re-fetch. + // With openWildcard, the anchor slides forward — no re-fetch. + session.update("play my favo", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + session.update("play my favorite", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("openWildcard=true: separator after slide shows completions from trie", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["by"], 11, { + closedSet: true, + openWildcard: true, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play my fav", getPos); + await Promise.resolve(); + + // Slide through non-separator chars + session.update("play my favorite", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + // Now type a space — separator present, completionPrefix="" + // Trie has "by", so the menu should show it. + session.update("play my favorite ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(menu.updatePrefix).toHaveBeenCalled(); + }); + + test("openWildcard=true: typing the keyword triggers B4 unique match → re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["by"], 11, { + closedSet: true, + openWildcard: true, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play my fav", getPos); + await Promise.resolve(); + + // Type separator + "by" → should match the trie entry exactly + session.update("play my fav by", getPos); + // "by" is uniquely satisfied → B4 triggers re-fetch for next level + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("openWildcard=false: non-separator after anchor triggers normal re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + closedSet: true, + openWildcard: false, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Without openWildcard, non-separator char triggers A3 re-fetch + session.update("playx", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("openWildcard=true with optional separator: C6 slide when trie empty", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["next"], 8, { + closedSet: true, + openWildcard: true, + separatorMode: "optional", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play foo", getPos); + await Promise.resolve(); + + // With optional separator, raw prefix goes straight to trie. + // "bar" doesn't match "next" → trie empty → C6 + // openWildcard=true → slide anchor, no re-fetch. + session.update("play foobar", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +});