fix: tighten the offline command parser

Renames, method, interceptor and collection-name commands no longer fire on loose matches.
This commit is contained in:
nivedin
2026-09-24 15:12:54 +05:30
committed by Nivedin
parent 6b114e9755
commit 9025bd3cb5
4 changed files with 265 additions and 42 deletions
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest"
import { parseAppActionCommand, splitCommands } from "../app-actions"
describe("set_interceptor", () => {
it.each([
["select interceptor browser", "browser"],
["select interceptor proxy", "proxy"],
["switch interceptor native", "native"],
["set the interceptor to the browser", "browser"],
["select the proxy interceptor", "proxy"],
["use the extension agent", "extension"],
["switch to agent", "agent"],
["use proxy", "proxy"],
])("reads the interceptor from %s", (text, interceptor) => {
expect(parseAppActionCommand(text)).toEqual({
name: "set_interceptor",
input: { interceptor },
})
})
it.each([
"set header User-Agent: foo",
"set header Proxy-Authorization: Basic abc",
])("leaves the header edit %s alone", (text) => {
expect(parseAppActionCommand(text)).toBeNull()
})
})
describe("create_collection", () => {
it.each([
"create a collection for the auth flows",
"create a collection with my requests",
"make a new collection",
])("needs a name for %s", (text) => {
expect(parseAppActionCommand(text)?.name).not.toBe("create_collection")
})
it.each([
["create a collection called Auth flows", "Auth flows"],
["create collection Users", "Users"],
["create a collection for login named Login", "Login"],
['create a collection "for the win"', "for the win"],
])("names the collection from %s", (text, name) => {
expect(parseAppActionCommand(text)).toEqual({
name: "create_collection",
input: { name },
})
})
})
describe("splitCommands", () => {
it("splits a tab action from the run", () => {
expect(splitCommands("open a new tab and run the request")).toEqual([
"open a new tab",
"run the request",
])
})
})
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"
import { parseRawKeyValueEntries } from "@hoppscotch/data"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { applyToolCall } from "../commands"
import { applyToolCall, runChatCommand, toRawKeyValueLines } from "../commands"
const withBody = (body: string, contentType = "application/json") => ({
...getDefaultRESTRequest(),
@@ -68,3 +69,122 @@ describe("set_body", () => {
})
})
})
describe("toRawKeyValueLines", () => {
it("escapes decoded values the raw parser would split", () => {
const lines = toRawKeyValueLines("note=line1%0Aline2&a%3Ab=1&plain=x")
expect(parseRawKeyValueEntries(lines)).toEqual([
{ key: "note", value: "line1\nline2", active: true },
{ key: "a:b", value: "1", active: true },
{ key: "plain", value: "x", active: true },
])
})
it("keeps simple pairs readable", () => {
expect(toRawKeyValueLines("a=1&b=two+words")).toBe("a: 1\nb: two words")
})
})
describe("runChatCommand rename", () => {
const req = () => ({
...getDefaultRESTRequest(),
headers: [{ key: "X-Old", value: "v", active: true, description: "" }],
})
it.each([
"rename header X-Old to X-New",
"set the name of header X-Old to X-New",
"rename the collection to Auth",
"rename param page to p",
])("leaves the request alone for %s", (text) => {
const r = req()
const res = runChatCommand(r, text)
expect(res.handled).toBe(false)
expect(r.name).toBe("Untitled")
expect(r.headers).toEqual(req().headers)
})
it.each([
["rename the request to Get users", "Get users"],
["rename to Login", "Login"],
["rename it to `List items`", "List items"],
["set the request name to Delete user", "Delete user"],
["change the name of this request to Fetch", "Fetch"],
["set the name to Search", "Search"],
])("renames the request for %s", (text, name) => {
const r = req()
const res = runChatCommand(r, text)
expect(res.changed).toBe(true)
expect(r.name).toBe(name)
expect(r.method).toBe("GET")
})
})
describe("runChatCommand method", () => {
it("sets the URL without reading a verb out of it", () => {
const r = getDefaultRESTRequest()
runChatCommand(
r,
"set the request url to https://api.example.com/delete-item"
)
expect(r.method).toBe("GET")
expect(r.endpoint).toBe("https://api.example.com/delete-item")
})
it.each([
["set method to post", "POST"],
["change the HTTP method to PUT", "PUT"],
["use the DELETE method", "DELETE"],
["make the request a PATCH", "PATCH"],
["make it a POST request", "POST"],
["switch to HEAD", "HEAD"],
["set request to POST", "POST"],
["change request to PUT", "PUT"],
["use POST for this request", "POST"],
])("sets the method for %s", (text, method) => {
const r = getDefaultRESTRequest()
const res = runChatCommand(r, text)
expect(res.changed).toBe(true)
expect(r.method).toBe(method)
})
it("does not read a method from a header edit", () => {
const r = getDefaultRESTRequest()
runChatCommand(r, "set header X-HTTP-Method-Override: PUT")
expect(r.method).toBe("GET")
expect(r.headers).toEqual([
{
key: "X-HTTP-Method-Override",
value: "PUT",
active: true,
description: "",
},
])
})
it("reports an unchanged method as no change", () => {
const r = getDefaultRESTRequest()
expect(runChatCommand(r, "set method to GET").changed).toBe(false)
})
})
describe("set_method", () => {
it("reports no change when the method already matches", () => {
const r = getDefaultRESTRequest()
expect(applyToolCall(r, "set_method", { method: "get" }).changed).toBe(
false
)
expect(applyToolCall(r, "set_method", { method: "POST" }).changed).toBe(
true
)
expect(r.method).toBe("POST")
})
})
@@ -125,15 +125,19 @@ export function parseAppActionCommand(text: string): AppActionCall | null {
// Interceptor / connection agent change
if (
/\binterceptor\b|\bagent\b|\bproxy\b/i.test(t) &&
/\binterceptor\b|(?<![\w-])(?:agent|proxy)(?![\w-])/i.test(t) &&
/\b(?:use|switch|change|set|select)\b/i.test(t)
) {
const hint = t
.replace(/.*\b(?:to|use)\b\s*/i, "")
.replace(/\binterceptor\b/gi, "")
let hint = t
.replace(/[.!?]+$/, "")
.replace(/^(?:the|a|an)\s+/i, "")
.replace(/.*\b(?:to|use)\b\s*/i, "")
.replace(/^(?:switch|change|set|select)\b\s*/i, "")
.replace(/\binterceptors?\b/gi, " ")
.replace(/^\s*(?:the|a|an)\s+/i, "")
.replace(/\s+/g, " ")
.trim()
// "Agent" and "Proxy" are interceptor names too: drop them only as a noun.
if (hint.includes(" ")) hint = hint.replace(/\s+(?:agent|proxy)$/i, "")
return { name: "set_interceptor", input: { interceptor: hint } }
}
@@ -157,14 +161,32 @@ export function parseAppActionCommand(text: string): AppActionCall | null {
}
}
// Collection: create
const createColl = looksLikeQuestion
? null
: t.match(
/\b(?:create|new|make)\b[^]*?\bcollection\b(?:\s+(?:called|named))?\s*["'`]?([\w][\w .-]*?)["'`]?\s*[.!?]*$/i
)
if (createColl && !isEnvStopword(createColl[1].trim())) {
return { name: "create_collection", input: { name: createColl[1].trim() } }
// Collection: create. A bare "collection for …" names no collection.
// Separate matches keep the lazy scans from nesting on long input.
if (
!looksLikeQuestion &&
/\b(?:create|new|make)\b[^]*?\bcollection\b/i.test(t)
) {
const named = t.match(
/\bcollection\b[^]*?\b(?:called|named)\s+(["'`]?)([\w][\w .-]*?)["'`]?\s*[.!?]*$/i
)
const bare = named
? null
: t.match(
/\b(?:create|new|make)\b[^]*?\bcollection\s+(["'`]?)([\w][\w .-]*?)["'`]?\s*[.!?]*$/i
)
const collName = (named ?? bare)?.[2].trim() ?? ""
if (
(named ||
(bare &&
(bare[1] ||
!/^(?:for|to|with|of|about|from|in|into|on|that|which|containing|using|called|named)\b/i.test(
collName
)))) &&
!isEnvStopword(collName)
) {
return { name: "create_collection", input: { name: collName } }
}
}
// Collection: save the current request into one
@@ -1,7 +1,8 @@
import type {
HoppGQLRequest,
HoppRESTRequest,
HoppRESTReqBody,
import {
rawKeyValueEntriesToString,
type HoppGQLRequest,
type HoppRESTRequest,
type HoppRESTReqBody,
} from "@hoppscotch/data"
export interface ChatCommandResult {
@@ -139,16 +140,18 @@ export function toRawKeyValueLines(body: string): string {
return part
}
}
return trimmed
.split("&")
.filter(Boolean)
.map((pair) => {
const eq = pair.indexOf("=")
const key = eq === -1 ? pair : pair.slice(0, eq)
const value = eq === -1 ? "" : pair.slice(eq + 1)
return `${decode(key)}: ${decode(value)}`
})
.join("\n")
// Escaped so a decoded newline or colon survives the raw parser.
return rawKeyValueEntriesToString(
trimmed
.split("&")
.filter(Boolean)
.map((pair) => {
const eq = pair.indexOf("=")
const key = eq === -1 ? pair : pair.slice(0, eq)
const value = eq === -1 ? "" : pair.slice(eq + 1)
return { key: decode(key), value: decode(value), active: true }
})
)
}
/**
@@ -216,11 +219,15 @@ export function runChatCommand(
}
}
// Rename request
m = t.match(
/\b(?:rename|(?:set|change|update)\s+(?:the\s+)?name)\b.*?(?:to|=|:)?\s*["'`]?(.+?)["'`]?$/i
)
if (m && /\b(?:rename|name)\b/i.test(t)) {
// Rename request: only the request itself, or a bare "rename to …"
m =
t.match(
/\brename\s+(?:(?:the|this|my|current)\s+)*(?:(?:request|tab|it)\s+)?(?:to|as)\s+["'`]?(.+?)["'`]?$/i
) ??
t.match(
/\b(?:set|change|update)\s+(?:(?:the|this|my|current)\s+)*(?:(?:request|tab)(?:'s)?\s+)?name(?:\s+of\s+(?:(?:the|this|my|current)\s+)*(?:request|tab|it))?\s*(?:to|as|=|:)\s*["'`]?(.+?)["'`]?$/i
)
if (m) {
if (!req) return NEED_REQUEST
const reqName = m[1].trim()
if (reqName) {
@@ -232,6 +239,10 @@ export function runChatCommand(
}
}
}
// Renaming a header, collection… isn't an offline edit.
if (/^(?:rename|(?:set|change|update)\s+(?:the\s+)?name\s+of)\b/i.test(t)) {
return { handled: false, reply: "" }
}
// Remove header
m = t.match(/\b(?:remove|delete|drop)\b.*?\bheader\b\s+["'`]?([\w.-]+)/i)
@@ -273,21 +284,32 @@ export function runChatCommand(
}
}
// Method
// Method: names "method", or "make the request a POST" / "switch to GET"
if (
/\b(?:set|change|switch|make|use)\b/i.test(t) &&
/\bmethod\b|\brequest\b/i.test(t)
!/\b(?:url|endpoint|address|body|headers?|param(?:eter)?s?|variables?)\b/i.test(
t
)
) {
const mm = t
.toUpperCase()
.match(new RegExp(`\\b(${CHAT_HTTP_METHODS.join("|")})\\b`))
const verbs = CHAT_HTTP_METHODS.join("|")
const mm =
t.match(new RegExp(`\\bmethod\\b[^]*?\\b(${verbs})\\b`, "i")) ??
t.match(new RegExp(`\\b(${verbs})\\b[^]*?\\bmethod\\b`, "i")) ??
t.match(
new RegExp(
`\\b(?:set|change|switch|make|use)\\s+(?:(?:(?:the|this)\\s+)?request\\s+|it\\s+|this\\s+)?(?:(?:to|as|into)\\s+)?(?:an?\\s+)?(${verbs})(?:\\s+(?:for\\s+(?:the\\s+|this\\s+)?)?request)?\\s*[.!]*$`,
"i"
)
)
if (mm) {
if (!req) return NEED_REQUEST
req.method = mm[1]
const method = mm[1].toUpperCase()
const changed = req.method !== method
req.method = method
return {
handled: true,
changed: true,
reply: `✓ Set the method to ${code(mm[1])}.`,
changed,
reply: `✓ Set the method to ${code(method)}.`,
}
}
}
@@ -561,10 +583,11 @@ export function applyToolCall(
changed: false,
reply: `Unsupported method ${code(method)}. Use one of: ${CHAT_HTTP_METHODS.join(", ")}.`,
}
const changed = req.method !== method
req.method = method
return {
handled: true,
changed: true,
changed,
reply: `✓ Set the method to ${code(method)}.`,
}
}