fix: make the assistant act on the right target

Tools now act on the tab and workspace the turn started in. Run and save
wait for the pinned tab to render, GraphQL runs see edits made earlier in
the same batch, request tools refuse off the workspace page, and the
context is read again before each step.

Collection deletes follow the sidebar: they delete by backend id,
re-index open tabs and flush local values. Collection tools take a
Parent/Child path and refuse an ambiguous name instead of guessing.

Runs to a host the assistant changed, script writes, publishing and
public mock servers ask first. A turn can be stopped, and a retry after
an error no longer repeats steps that already ran.
This commit is contained in:
nivedin
2026-09-24 15:12:54 +05:30
committed by Nivedin
parent 51bce761f4
commit 7a644989d7
17 changed files with 7408 additions and 657 deletions
+30 -3
View File
@@ -2080,8 +2080,20 @@
"modify_with_ai": "Modify with AI",
"generate": "Generate",
"generate_or_modify_request_body_input_placeholder": "Enter your prompt to modify request body",
"confirm_delete_collection": "Delete \"{name}\" and everything in it?",
"confirm_delete_mock_server": "Delete the mock server \"{name}\"?",
"confirm_delete_collection": "Delete \"{name}\" and everything in it from {workspace}?",
"confirm_delete_mock_server": "Delete the mock server \"{name}\" from {workspace}?",
"confirm_run": "Run \"{name}\"? The assistant just changed: {reasons}.",
"run_reason_host": "URL host",
"run_reason_host_named": "URL host (now {host})",
"run_reason_script": "scripts",
"run_reason_env": "variables",
"confirm_publish_docs": "Publish \"{name}\" docs ({version}) publicly?",
"confirm_publish_docs_env": "Publish \"{name}\" docs ({version}) with \"{environment}\"? Its values become public.",
"confirm_unpublish_docs": "Unpublish \"{name}\" docs ({version})? Its link stops working.",
"confirm_public_mock_server": "Make mock server \"{name}\" public? Anyone with its URL can call it.",
"confirm_save_script": "Save the assistant's script to \"{name}\" in {workspace}?",
"confirm_save": "Save to \"{name}\" in {workspace}? The assistant just changed: {reasons}.",
"team_workspace": "a team workspace",
"accept_change": "Accept Change",
"feedback_success": "Feedback submitted successfully",
"feedback_failure": "Failed to submit feedback",
@@ -2105,6 +2117,7 @@
"thinking": "Thinking…",
"thought_for": "Thought for {duration}",
"send": "Send",
"stop": "Stop",
"copy": "Copy",
"copied": "Copied",
"suggestions": "Suggestions",
@@ -2125,7 +2138,21 @@
"no_models_subtitle": "An administrator needs to add a provider in the admin dashboard before the assistant can answer.",
"skills": "Skills",
"skill_custom": "Custom",
"skills_empty": "No skill matches that"
"skills_empty": "No skill matches that",
"errors": {
"unauthorized": "Your session expired. Sign in and resend.",
"rate_limited": "Too many requests. Wait a moment and retry.",
"input_too_large": "This chat is too long. Clear it and retry.",
"invalid_input": "The AI service rejected this chat. Clear it and retry.",
"model_unavailable": "That model is gone. Pick another and resend.",
"chat_disabled": "The assistant is off on this server. Ask your admin.",
"provider_misconfigured": "The AI provider rejected this server's setup. Ask your admin.",
"provider_timeout": "The AI service took too long. Try again.",
"network": "Couldn't reach the server. Check your connection.",
"unable_to_parse_response": "The AI service sent an unexpected reply. Try again.",
"cannot_run_chat": "The AI service couldn't answer. Try again.",
"aborted": "Stopped."
}
}
},
"configs": {
@@ -22,16 +22,17 @@ import {
import { restCollections$ } from "~/newstore/collections"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { teamCollToHoppRESTColl } from "~/helpers/backend/helpers"
import { truncateText } from "~/helpers/aichat/context-serializers"
import { isActionBound } from "~/helpers/actions"
import TeamEnvironmentAdapter from "~/helpers/teams/TeamEnvironmentAdapter"
import type { TeamAccessRole } from "~/helpers/backend/graphql"
import type { HoppRESTResponse } from "~/helpers/types/HoppRESTResponse"
import type { HoppTabDocument } from "~/helpers/tab/document"
import type { HoppTab } from "~/services/tab"
import { AIChatService, type ChatContextItem } from "~/services/ai-chat.service"
export type { ChatContextItem }
const truncate = (value: string, max: number) =>
value.length > max ? `${value.slice(0, max)}…[truncated]` : value
// Every character here is re-sent on each model round-trip, so the caps are
// deliberately tight — the model asks for more when it needs it.
const BODY_CHARS = 1000
@@ -39,19 +40,32 @@ const HEADER_VALUE_CHARS = 120
const MAX_RESPONSE_HEADERS = 12
const MAX_ENV_NAMES = 20
const headerLines = (headers: Array<{ key: string; value: string }>) =>
const OFF_PAGE_NOTE =
"No request is open: the user is on another page. Request and tab tools refuse until they open the workspace."
/** Puts resolved credentials back behind their references. */
type Mask = (text: string) => string
// Masked before the cut: a truncated credential no longer matches its value.
const clip = (value: string, max: number, mask: Mask) =>
truncateText(mask(value), max)
const headerLines = (
headers: Array<{ key: string; value: string }>,
mask: Mask
) =>
headers
.map((h) => `- ${h.key}: ${truncate(h.value, HEADER_VALUE_CHARS)}`)
.map((h) => `- ${h.key}: ${clip(h.value, HEADER_VALUE_CHARS, mask)}`)
.join("\n")
const serializeRequest = (req: HoppRESTRequest): string => {
const serializeRequest = (req: HoppRESTRequest, mask: Mask): string => {
const lines: string[] = [
"### Current request",
`${req.method} ${req.endpoint}`,
]
if (req.description?.trim()) {
lines.push(`Documentation:\n${truncate(req.description, 400)}`)
lines.push(`Documentation:\n${clip(req.description, 400, mask)}`)
}
const params = (req.params ?? []).filter((p) => p.active && p.key)
@@ -63,7 +77,7 @@ const serializeRequest = (req: HoppRESTRequest): string => {
const headers = (req.headers ?? []).filter((h) => h.active && h.key)
if (headers.length) {
lines.push("Headers:\n" + headerLines(headers))
lines.push("Headers:\n" + headerLines(headers, mask))
}
if (
@@ -82,13 +96,13 @@ const serializeRequest = (req: HoppRESTRequest): string => {
body.body.trim()
) {
const contentType = "contentType" in body ? body.contentType : "raw"
lines.push(`Body (${contentType}):\n${truncate(body.body, BODY_CHARS)}`)
lines.push(`Body (${contentType}):\n${clip(body.body, BODY_CHARS, mask)}`)
}
return lines.join("\n")
}
const serializeResponse = (res: HoppRESTResponse): string => {
const serializeResponse = (res: HoppRESTResponse, mask: Mask): string => {
if (res.type !== "success" && res.type !== "failure") return ""
const lines: string[] = [
@@ -102,7 +116,7 @@ const serializeResponse = (res: HoppRESTResponse): string => {
.filter((h: { key: string; value: string }) => h.key)
.slice(0, MAX_RESPONSE_HEADERS)
if (headers.length) {
lines.push("Headers:\n" + headerLines(headers))
lines.push("Headers:\n" + headerLines(headers, mask))
}
let bodyText = ""
@@ -112,7 +126,7 @@ const serializeResponse = (res: HoppRESTResponse): string => {
bodyText = ""
}
if (bodyText.trim()) {
lines.push(`Body (truncated):\n${truncate(bodyText, BODY_CHARS)}`)
lines.push(`Body (truncated):\n${clip(bodyText, BODY_CHARS, mask)}`)
}
return lines.join("\n")
@@ -120,7 +134,8 @@ const serializeResponse = (res: HoppRESTResponse): string => {
const serializeGQLRequest = (
req: HoppGQLRequest,
hasSchema: boolean
hasSchema: boolean,
mask: Mask
): string => {
const lines: string[] = [
"### Current request (GraphQL)",
@@ -129,7 +144,7 @@ const serializeGQLRequest = (
const headers = (req.headers ?? []).filter((h) => h.active && h.key)
if (headers.length) {
lines.push("Headers:\n" + headerLines(headers))
lines.push("Headers:\n" + headerLines(headers, mask))
}
if (
@@ -141,7 +156,7 @@ const serializeGQLRequest = (
}
if (req.query?.trim()) {
lines.push(`Query:\n${truncate(req.query, BODY_CHARS)}`)
lines.push(`Query:\n${clip(req.query, BODY_CHARS, mask)}`)
// A document can hold several operations — spell them out so the model
// can pick one via run_request { operation: "<name>" }.
@@ -167,7 +182,7 @@ const serializeGQLRequest = (
}
}
if (req.variables?.trim()) {
lines.push(`Query variables:\n${truncate(req.variables, 500)}`)
lines.push(`Query variables:\n${clip(req.variables, 500, mask)}`)
}
lines.push(
@@ -188,7 +203,10 @@ const lastGQLResponse = (events: GQLResponseEvent[]) => {
return null
}
const serializeGQLResponse = (events: GQLResponseEvent[]): string => {
const serializeGQLResponse = (
events: GQLResponseEvent[],
mask: Mask
): string => {
const last = lastGQLResponse(events)
if (!last) return ""
@@ -202,7 +220,7 @@ const serializeGQLResponse = (events: GQLResponseEvent[]): string => {
)
}
if (last.data?.trim()) {
lines.push(`Body (truncated):\n${truncate(last.data, BODY_CHARS)}`)
lines.push(`Body (truncated):\n${clip(last.data, BODY_CHARS, mask)}`)
}
return lines.join("\n")
@@ -252,11 +270,15 @@ const serializeWorkspace = (
* Builds the live, toggleable context for the AI chat from whatever the user is
* currently looking at — the active REST request, its latest response, and the
* selected environment.
*
* @param active Whether the assistant is in use; team environments are only
* fetched and subscribed to while it is.
*/
export function useChatContext() {
export function useChatContext(active: () => boolean = () => true) {
const restTabs = useService(WorkspaceTabsService)
const gqlTabConn = useService(GQLTabConnectionService)
const chat = useService(AIChatService)
const mask: Mask = (text) => chat.maskLocalSecrets(text)
const workspaceService = useService(WorkspaceService)
const currentEnv = useReadonlyStream(
@@ -281,27 +303,23 @@ export function useChatContext() {
// component so a remounted layout does not double-subscribe.
tryOnScopeDispose(() => teamEnvAdapter.unsubscribeSubscriptions())
const teamCollectionService = useService(TeamCollectionsService)
// Only pages/index.vue binds this; elsewhere its tabs are hidden.
const onWorkspacePage = isActionBound("rest.request.open")
watch(
workspaceService.currentWorkspace,
(ws) => {
teamEnvAdapter.changeTeamID(ws.type === "team" ? ws.teamID : undefined)
() => {
const ws = workspaceService.currentWorkspace.value
return active() && ws.type === "team" ? ws.teamID : undefined
},
(teamID) => teamEnvAdapter.changeTeamID(teamID),
{ immediate: true }
)
const activeRequestDoc = computed(() => {
const doc = restTabs.currentActiveTab.value?.document
return doc && doc.type === "request" ? doc : null
})
// The unified workspace also hosts GraphQL request tabs.
const activeGQLRequestDoc = computed(() => {
const doc = restTabs.currentActiveTab.value?.document
return doc && doc.type === "gql-request" ? doc : null
})
const items = computed<ChatContextItem[]>(() => {
/** The context items for `tab` and the workspace around it. */
const buildItems = (
tab: HoppTab<HoppTabDocument> | null | undefined
): ChatContextItem[] => {
const out: ChatContextItem[] = []
const offPage = !onWorkspacePage.value
const workspace = workspaceService.currentWorkspace.value
out.push({
@@ -311,17 +329,20 @@ export function useChatContext() {
workspace.type === "team"
? `Team workspace: ${workspace.teamName}`
: "Personal workspace",
serialize: () => serializeWorkspace(workspace, teamList.value ?? []),
serialize: () =>
serializeWorkspace(workspace, teamList.value ?? []) +
(offPage ? `\n${OFF_PAGE_NOTE}` : ""),
})
const doc = activeRequestDoc.value
const tabDoc = offPage ? undefined : tab?.document
const doc = tabDoc?.type === "request" ? tabDoc : null
if (doc?.request) {
const req = doc.request
out.push({
id: "request",
label: "Request",
detail: `${req.method} ${req.endpoint || "—"}`,
serialize: () => serializeRequest(req),
serialize: () => serializeRequest(req, mask),
})
const res = doc.response
@@ -330,21 +351,25 @@ export function useChatContext() {
id: "response",
label: "Response",
detail: `${res.statusCode} ${res.statusText}`,
serialize: () => serializeResponse(res),
serialize: () => serializeResponse(res, mask),
})
}
}
const gqlDoc = activeGQLRequestDoc.value
if (gqlDoc?.request) {
// The unified workspace also hosts GraphQL request tabs.
const gqlDoc = tabDoc?.type === "gql-request" ? tabDoc : null
if (tab && gqlDoc?.request) {
const req = gqlDoc.request
const schema = gqlTabConn.activeTabSchema.value
const schema =
tab.id === restTabs.currentActiveTab.value?.id
? gqlTabConn.activeTabSchema.value
: gqlTabConn.getTabConnectionState(tab.id).schema
out.push({
id: "request",
label: "Request",
detail: `GQL ${req.url || "—"}`,
serialize: () => serializeGQLRequest(req, !!schema),
serialize: () => serializeGQLRequest(req, !!schema, mask),
})
// The introspected schema (once the tab has connected) — this is what
@@ -370,7 +395,7 @@ export function useChatContext() {
detail: last.document
? `${last.document.statusCode} ${last.document.statusText}`
: "GraphQL response",
serialize: () => serializeGQLResponse(events),
serialize: () => serializeGQLResponse(events, mask),
})
}
}
@@ -426,7 +451,9 @@ export function useChatContext() {
out.push(...chat.registeredContext.value)
return out
})
}
const items = computed(() => buildItems(restTabs.currentActiveTab.value))
// Context ids the user has explicitly toggled off (everything is on by default).
const excluded = ref<Set<string>>(new Set())
@@ -440,14 +467,30 @@ export function useChatContext() {
excluded.value = next
}
const contextString = computed(() =>
items.value
.filter((item) => isIncluded(item.id))
.map((item) => item.serialize())
.join("\n\n")
)
// A credential the chat resolved into the request goes back as its reference.
const serializeItems = (list: ChatContextItem[]) =>
chat.maskLocalSecrets(
list
.filter((item) => isIncluded(item.id))
.map((item) => item.serialize())
.join("\n\n")
)
return { items, isIncluded, toggle, contextString }
/**
* The context for the tab a turn is pinned to (null: the active one), built
* fresh on each call so every step sees what the last one opened or ran.
* Never cached: the secrets to mask aren't reactive.
*/
const contextFor = (tabId: string | null) => {
const active = restTabs.currentActiveTab.value
const tab =
!tabId || active?.id === tabId
? active
: restTabs.getActiveTabs().value.find((t) => t.id === tabId)
return serializeItems(buildItems(tab))
}
return { items, isIncluded, toggle, contextFor }
}
/**
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest"
import type { HoppCollection } from "@hoppscotch/data"
import { findCollectionByName } from "../collections"
import type { HoppCollection, HoppRESTRequest } from "@hoppscotch/data"
import {
describeAmbiguous,
findRequestInTree,
lookupCollection,
lookupRequest,
pickByName,
} from "../collections"
const node = (
name: string,
@@ -17,25 +23,176 @@ const node = (
variables: [],
}) as unknown as HoppCollection
describe("findCollectionByName", () => {
const req = (name: string, endpoint = "") =>
({ name, endpoint, method: "GET" }) as unknown as HoppRESTRequest
describe("lookupCollection", () => {
const tree = [
node("Users", [node("Auth", [node("Tokens")]), node("Profiles")]),
node("Orders", [node("Auth")]),
node("Billing", [node("v1")]),
node("Payments", [node("v1")]),
]
test("prefers a top-level collection", () => {
expect(findCollectionByName(tree, "orders")).toMatchObject({ path: "1" })
})
test("falls back to the shallowest nested folder by name", () => {
expect(findCollectionByName(tree, "Auth")).toMatchObject({ path: "0/0" })
expect(findCollectionByName(tree, "tokens")).toMatchObject({
path: "0/0/0",
test("finds a unique name at any depth, with its path and label", () => {
expect(lookupCollection(tree, "orders")).toMatchObject({
found: { path: "1", label: "Orders" },
})
expect(lookupCollection(tree, "tokens")).toMatchObject({
found: { path: "0/0/0", label: "Users/Auth/Tokens" },
})
})
// Deleting "v1 under Billing" used to hit Payments' v1 or the first one found.
test("reports a repeated name instead of picking one", () => {
expect(lookupCollection(tree, "v1")).toEqual({
ambiguous: ["Billing/v1", "Payments/v1"],
})
expect(lookupCollection(tree, "Auth")).toEqual({
ambiguous: ["Users/Auth", "Orders/Auth"],
})
})
test("a top-level collection no longer shadows a same-named folder", () => {
const shadow = [node("Auth"), node("Users", [node("Auth")])]
expect(lookupCollection(shadow, "Auth")).toEqual({
ambiguous: ["Auth", "Users/Auth"],
})
// A leading slash anchors at the top level.
expect(lookupCollection(shadow, "/Auth")).toMatchObject({
found: { path: "0" },
})
})
test("resolves a Parent/Child path", () => {
expect(lookupCollection(tree, "Payments/v1")).toMatchObject({
found: { path: "3/0", label: "Payments/v1" },
})
expect(lookupCollection(tree, " orders / auth ")).toMatchObject({
found: { path: "1/0" },
})
expect(lookupCollection(tree, "/Users/Auth/Tokens")).toMatchObject({
found: { path: "0/0/0" },
})
})
test("an anchored path must start at the top level", () => {
expect(lookupCollection(tree, "/Auth/Tokens")).toBeNull()
})
test("still finds a name that itself contains a slash", () => {
const slashed = [node("users/v1"), node("Other")]
expect(lookupCollection(slashed, "users/v1")).toMatchObject({
found: { path: "0" },
})
})
// The refusal lists "/API/v1/beta"; that path must resolve.
test("the paths a slashed-name refusal lists resolve", () => {
const tree = [
node("API", [node("v1/beta")]),
node("Web", [node("v1/beta")]),
]
const lookup = lookupCollection(tree, "v1/beta")
expect(lookup).toEqual({ ambiguous: ["API/v1/beta", "Web/v1/beta"] })
expect(lookupCollection(tree, "/API/v1/beta")).toMatchObject({
found: { path: "0/0" },
})
expect(lookupCollection(tree, "Web/v1/beta")).toMatchObject({
found: { path: "1/0" },
})
})
test("a bare name never matches part of a slashed one", () => {
expect(lookupCollection([node("users/v1")], "v1")).toBeNull()
})
test("returns null for unknown or empty names", () => {
expect(findCollectionByName(tree, "Payments")).toBeNull()
expect(findCollectionByName(tree, " ")).toBeNull()
expect(lookupCollection(tree, "Nope")).toBeNull()
expect(lookupCollection(tree, " ")).toBeNull()
expect(lookupCollection(tree, "/")).toBeNull()
})
test("the ambiguity reply lists paths the model can pass back", () => {
const reply = describeAmbiguous("collections", "v1", [
"Billing/v1",
"Payments/v1",
])
expect(reply).toContain("/Billing/v1, /Payments/v1")
expect(reply).not.toContain("rename")
expect(describeAmbiguous("collections", "A", ["A", "A"])).toContain(
"rename one first"
)
})
})
describe("request lookups", () => {
const tree = [
node(
"Auth",
[node("v2", [], [req("Login")])],
[
req("Login", "https://api.example/login"),
req("Get users", "https://api.example/users"),
req("Get user by id", "https://api.example/users/1"),
]
),
node("Billing", [], [req("Login")]),
]
// A write must not land on "Login" when asked for a request that doesn't exist.
test("lookupRequest matches exact names only", () => {
expect(lookupRequest(tree, "Login with refresh token", "Auth")).toBeNull()
expect(lookupRequest(tree, "Get user", "Auth")).toBeNull()
expect(lookupRequest(tree, "users/1", "Auth")).toBeNull()
expect(lookupRequest(tree, "get users", "Auth")).toMatchObject({
found: { folderPath: "0", requestIndex: 1, label: "Auth/Get users" },
})
})
test("lookupRequest reports a name repeated in scope", () => {
expect(lookupRequest(tree, "Login")).toEqual({
ambiguous: ["Auth/Login", "Auth/v2/Login", "Billing/Login"],
})
expect(lookupRequest(tree, "Login", "Billing")).toMatchObject({
found: { folderPath: "1", requestIndex: 0 },
})
expect(lookupRequest(tree, "Login", "Auth/v2")).toMatchObject({
found: { folderPath: "0/0", requestIndex: 0 },
})
})
test("findRequestInTree stays tolerant and accepts a path scope", () => {
expect(findRequestInTree(tree, "Get user", "Auth")?.request.name).toBe(
"Get users"
)
expect(findRequestInTree(tree, "login", "Auth/v2")).toMatchObject({
folderPath: "0/0",
requestIndex: 0,
})
expect(findRequestInTree(tree, "Login", "Nope")).toBeNull()
})
})
describe("pickByName", () => {
const envs = ["Preprod", "Production", "Staging"]
const pick = (name: string) => pickByName(envs, name, (e) => e)
test("an exact match wins over partial ones", () => {
expect(pick("production")).toEqual({ item: "Production" })
})
test("a unique partial match is taken", () => {
expect(pick("stag")).toEqual({ item: "Staging" })
})
// "prod" used to select Preprod just because it came first.
test("several partial matches are reported, not guessed", () => {
expect(pick("prod")).toEqual({ ambiguous: ["Preprod", "Production"] })
})
test("returns null for no match", () => {
expect(pick("qa")).toBeNull()
expect(pick(" ")).toBeNull()
})
})
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { applyToolCall } from "../commands"
const withBody = (body: string, contentType = "application/json") => ({
...getDefaultRESTRequest(),
body: { contentType, body },
})
describe("set_body", () => {
it("serializes an object body instead of keeping the old one", () => {
const req = withBody('{"old":true}') as never
const res = applyToolCall(req, "set_body", {
body: { name: "new" },
contentType: "application/json",
})
expect(res.changed).toBe(true)
expect(JSON.parse((req as { body: { body: string } }).body.body)).toEqual({
name: "new",
})
})
it("sends an object body as JSON when no content type is given", () => {
const req = withBody("plain", "text/plain") as never
applyToolCall(req, "set_body", { body: [1, 2] })
expect((req as { body: unknown }).body).toEqual({
contentType: "application/json",
body: "[\n 1,\n 2\n]",
})
})
// A JSON:API server answers 415 to plain application/json.
it.each([
"application/vnd.api+json",
"application/hal+json",
"application/ld+json",
])("keeps the request's %s for an object body", (type) => {
const req = withBody("{}", type) as never
applyToolCall(req, "set_body", { body: { data: { id: "1" } } })
expect((req as { body: { contentType: string } }).body.contentType).toBe(
type
)
})
it("refuses an object body for a text content type", () => {
const req = withBody("keep") as never
const res = applyToolCall(req, "set_body", {
body: { a: 1 },
contentType: "text/plain",
})
expect(res.changed).toBe(false)
expect(res.reply).toMatch(/^⚠️/)
expect((req as { body: { body: string } }).body.body).toBe("keep")
})
it("still changes only the content type when no body is sent", () => {
const req = withBody("<a/>", "text/plain") as never
const res = applyToolCall(req, "set_body", { contentType: "text/xml" })
expect(res.changed).toBe(true)
expect((req as { body: unknown }).body).toEqual({
contentType: "text/xml",
body: "<a/>",
})
})
})
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest"
import { makeCollection, type HoppCollection } from "@hoppscotch/data"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { serializeCollections, truncateText } from "../context-serializers"
/** A lone surrogate makes the provider reject the whole request body. */
const LONE_SURROGATE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
describe("truncateText", () => {
it("never splits an emoji at the cut", () => {
const text = `${"x".repeat(999)}\u{1F600}tail`
const out = truncateText(text, 1000)
expect(out).not.toMatch(LONE_SURROGATE)
expect(out).toBe(`${"x".repeat(999)}…[truncated]`)
})
it("keeps text under the cap untouched", () => {
expect(truncateText("\u{1F600}", 2)).toBe("\u{1F600}")
})
})
describe("serializeCollections", () => {
const folder = (
name: string,
folders: HoppCollection[] = [],
requests = 0
): HoppCollection =>
makeCollection({
name,
folders,
requests: Array.from({ length: requests }, (_, i) => ({
...getDefaultRESTRequest(),
name: `${name}-req${i}`,
})),
auth: { authType: "inherit", authActive: true },
headers: [],
variables: [],
} as never)
it("says when folders nest deeper than the outline shows", () => {
const tree = [
folder("Root", [
folder("L1", [
folder("L2", [folder("L3", [folder("L4-Deep", [], 1)])]),
]),
]),
]
const out = serializeCollections(tree)
expect(out).not.toContain("L4-Deep")
expect(out).toContain("…1 more folder nested deeper")
})
it("adds no marker when nothing is hidden", () => {
const out = serializeCollections([folder("Root", [folder("L1", [], 1)])])
expect(out).not.toMatch(/nested deeper|truncated/)
})
})
@@ -1,3 +1,5 @@
import { readdirSync, readFileSync } from "node:fs"
import { resolve } from "node:path"
import { describe, expect, it } from "vitest"
import { parseStepLines, repliesFailure } from "../step-lines"
@@ -16,6 +18,7 @@ describe("parseStepLines", () => {
"📁 Created collection **fakestore**.",
"🗂️ Opened a new tab.",
"🗙 Closed the tab.",
"🗑️ Deleted **Payments** and everything in it.",
"💾 Saved the request.",
"👥 Created team **acme** and switched to it.",
"🏠 Switched to your personal workspace.",
@@ -42,6 +45,7 @@ describe("parseStepLines", () => {
["collection", "neutral"],
["tab", "neutral"],
["closed", "neutral"],
["deleted", "neutral"],
["saved", "neutral"],
["team", "neutral"],
["personal", "neutral"],
@@ -186,4 +190,41 @@ describe("repliesFailure", () => {
it("treats an empty reply as no verdict", () => {
expect(repliesFailure("", true)).toBe(false)
})
it("does not flag a completed delete", () => {
expect(
repliesFailure(
"\u{1F5D1}\uFE0F Deleted **Payments** and everything in it.",
true
)
).toBe(false)
})
})
describe("glyph vocabulary", () => {
// A glyph the parser doesn't know turns a success into an unmarked "note",
// which goes back to the model as a failure.
const helpers = resolve(__dirname, "..")
const sources = [
resolve(helpers, "../../services/ai-chat.service.ts"),
...readdirSync(helpers)
.filter((f) => f.endsWith(".ts") && f !== "step-lines.ts")
.map((f) => resolve(helpers, f)),
]
const LEADING_GLYPH =
/(?:["'`]|\\n)([\u2190-\u2BFF\u{1F000}-\u{1FAFF}]\uFE0F?)/gu
const emitted = new Set<string>()
for (const file of sources) {
for (const [, glyph] of readFileSync(file, "utf8").matchAll(LEADING_GLYPH))
emitted.add(glyph)
}
it("finds the service's glyphs", () => {
expect(emitted.size).toBeGreaterThan(15)
})
it.each([...emitted])("knows %s", (glyph) => {
expect(parseStepLines(`${glyph} x`)[0].kind).not.toBe("note")
})
})
@@ -27,41 +27,148 @@ export function listRequestNames(
return names.join(", ")
}
/** How a lookup reads a tree: personal (`folders`) and team (`children`) alike. */
export interface TreeAccess<T> {
name: (node: T) => string
children: (node: T) => T[] | null | undefined
}
/** A node a collection argument named, and where it sits. */
export interface TreeMatch<T> {
node: T
/** Ancestors from the top level down, the node excluded. */
ancestors: T[]
/** Index of each level from the top down, the node included. */
indices: number[]
/** "Parent/Child" — what replies and confirmations show. */
label: string
}
/** A unique hit, several (their labels), or none. */
export type Lookup<F> = { found: F } | { ambiguous: string[] } | null
/**
* A collection argument: a bare name, a "Parent/Child" path matched against
* the tail of each node's path, or "/Parent/Child" anchored at the top level.
*/
/** A name or path as lowercase "/"-separated parts. */
const pathParts = (text: string) =>
text
.split("/")
.map((s) => s.trim().toLowerCase())
.filter(Boolean)
export function parseCollectionRef(ref: string) {
const raw = ref.trim()
return {
anchored: raw.startsWith("/"),
segments: pathParts(raw),
}
}
const display = (name: string) => name.trim() || "Untitled"
/**
* Every node `ref` names (see `parseCollectionRef`), shallowest first. Never
* picks one: callers that act on a single node must refuse on several.
*
* A name with "/" in it counts as several parts, so the "/Top/v1/beta" a
* refusal lists resolves for a folder named "v1/beta" too.
*/
export function matchTreeNodes<T>(
roots: T[],
ref: string,
access: TreeAccess<T>
): TreeMatch<T>[] {
const { anchored, segments } = parseCollectionRef(ref)
if (!segments.length) return []
const out: TreeMatch<T>[] = []
const walk = (
nodes: T[],
ancestors: T[],
indices: number[],
chain: string[],
starts: number[]
) => {
nodes.forEach((node, i) => {
const own = pathParts(access.name(node))
const names = [...chain, ...own]
const nameStarts = [...starts, chain.length]
// The match must begin where a name begins and cover this node's name.
const from = names.length - segments.length
const matched =
own.length > 0 &&
from >= 0 &&
(anchored ? from === 0 : nameStarts.includes(from)) &&
segments.every((s, k) => names[from + k] === s)
if (matched) {
out.push({
node,
ancestors,
indices: [...indices, i],
label: [...ancestors, node]
.map((n) => display(access.name(n)))
.join("/"),
})
}
walk(
access.children(node) ?? [],
[...ancestors, node],
[...indices, i],
names,
nameStarts
)
})
}
walk(roots, [], [], [], [])
// Stable: tree order within a depth.
return out.sort((a, b) => a.indices.length - b.indices.length)
}
/** Reply for an argument that matched several nodes: lists their paths. */
export function describeAmbiguous(
what: string,
ref: string,
labels: string[],
hint = "pass one of these paths"
): string {
const shared = new Set(labels).size < labels.length
return `Several ${what} match "${ref}": ${labels
.map((l) => `/${l}`)
.join(", ")} — ${hint}.${
shared ? " Some share a path; rename one first." : ""
}`
}
const REST_TREE: TreeAccess<HoppCollection> = {
name: (c) => c.name ?? "",
children: (c) => c.folders,
}
export interface FoundCollection {
collection: HoppCollection
/** Index path of the node, e.g. "0" (top-level) or "0/2" (nested folder). */
path: string
/** "Parent/Child", for replies and confirmations. */
label: string
}
/**
* Finds a collection or folder by name (case-insensitive), preferring a
* top-level match and otherwise the shallowest nested folder — the context
* lists both identically, so the model refers to either the same way.
* Resolves a collection or folder argument (name or path, case-insensitive).
* A name shared by several nodes is reported, never guessed: these lookups
* precede deletes and overwrites.
*/
export function findCollectionByName(
export function lookupCollection(
collections: HoppCollection[],
name: string
): FoundCollection | null {
const n = name.trim().toLowerCase()
if (!n) return null
const top = findTopLevelCollection(collections, name)
if (top) return { collection: top.collection, path: String(top.index) }
// Breadth-first so the shallowest folder wins on duplicate names.
const queue: Array<{ node: HoppCollection; path: string }> = collections.map(
(c, i) => ({ node: c, path: String(i) })
)
while (queue.length) {
const { node, path } = queue.shift()!
for (const [i, folder] of (node.folders ?? []).entries()) {
const childPath = `${path}/${i}`
if ((folder.name ?? "").toLowerCase() === n) {
return { collection: folder, path: childPath }
}
queue.push({ node: folder, path: childPath })
}
ref: string
): Lookup<FoundCollection> {
const matches = matchTreeNodes(collections, ref, REST_TREE)
if (!matches.length) return null
if (matches.length > 1) return { ambiguous: matches.map((m) => m.label) }
const [m] = matches
return {
found: { collection: m.node, path: m.indices.join("/"), label: m.label },
}
return null
}
/** Finds a top-level collection by name (case-insensitive). */
@@ -74,48 +181,110 @@ export function findTopLevelCollection(
return index === -1 ? null : { collection: collections[index], index }
}
/** Every request in the tree, or under the nodes `collectionRef` names. */
function requestsInScope(
collections: HoppCollection[],
collectionRef?: string
): Array<FoundRequest & { label: string }> {
const scope = collectionRef?.trim()
? new Set(
matchTreeNodes(collections, collectionRef, REST_TREE).map((m) => m.node)
)
: null
const out: Array<FoundRequest & { label: string }> = []
const walk = (
nodes: HoppCollection[],
path: number[],
labels: string[],
inScope: boolean
) => {
nodes.forEach((c, ci) => {
const childPath = [...path, ci]
const childLabels = [...labels, display(c.name ?? "")]
const here = inScope || !!scope?.has(c)
if (here) {
;(c.requests ?? []).forEach((req, ri) => {
const request = req as HoppRESTRequest
out.push({
request,
folderPath: childPath.join("/"),
requestIndex: ri,
label: [...childLabels, display(request.name ?? "")].join("/"),
})
})
}
walk(c.folders ?? [], childPath, childLabels, here)
})
}
walk(collections, [], [], !scope)
return out
}
/**
* Finds the first request matching `reqName` in the tree, optionally restricted
* to the subtree of a collection/folder named `collectionName`. Returns its
* index-path folder and request index (what the tab save-context needs).
* to the subtree of the collections/folders `collectionRef` names. Tolerant
* (substring, endpoint): only for read-only uses like opening a tab.
*/
export function findRequestInTree(
collections: HoppCollection[],
reqName: string,
collectionName?: string
collectionRef?: string
): FoundRequest | null {
const target = reqName.trim().toLowerCase()
const scope = collectionName?.trim().toLowerCase()
// Collect every request in scope, then rank matches — tolerant of the model
// passing a slightly-off name (e.g. with the method prefix, or the endpoint).
const candidates: FoundRequest[] = []
const walk = (nodes: HoppCollection[], path: number[], inScope: boolean) => {
nodes.forEach((c, ci) => {
const childPath = [...path, ci]
const here = !scope || inScope || (c.name ?? "").toLowerCase() === scope
if (here) {
;(c.requests ?? []).forEach((req, ri) => {
candidates.push({
request: req as HoppRESTRequest,
folderPath: childPath.join("/"),
requestIndex: ri,
})
})
}
walk(c.folders ?? [], childPath, here)
})
}
walk(collections, [], !scope)
const candidates = requestsInScope(collections, collectionRef)
const name = (f: FoundRequest) => (f.request.name ?? "").toLowerCase()
const endpoint = (f: FoundRequest) => (f.request.endpoint ?? "").toLowerCase()
return (
const hit =
candidates.find((f) => name(f) === target) ??
candidates.find((f) => name(f) && name(f).includes(target)) ??
candidates.find((f) => name(f) && target.includes(name(f))) ??
candidates.find((f) => endpoint(f).includes(target)) ??
null
candidates.find((f) => endpoint(f).includes(target))
return hit
? {
request: hit.request,
folderPath: hit.folderPath,
requestIndex: hit.requestIndex,
}
: null
}
/**
* Exact (case-insensitive) request-name lookup for writes: a near miss must
* not overwrite another request, and a repeated name is reported.
*/
export function lookupRequest(
collections: HoppCollection[],
reqName: string,
collectionRef?: string
): Lookup<FoundRequest & { label: string }> {
const target = reqName.trim().toLowerCase()
if (!target) return null
const hits = requestsInScope(collections, collectionRef).filter(
(f) => (f.request.name ?? "").trim().toLowerCase() === target
)
if (!hits.length) return null
if (hits.length > 1) return { ambiguous: hits.map((h) => h.label) }
return { found: hits[0] }
}
/**
* Picks an item by name: an exact (case-insensitive) match, else a UNIQUE
* partial match; several partial matches are reported, not guessed.
*/
export function pickByName<T>(
items: T[],
name: string,
nameOf: (item: T) => string
): { item: T } | { ambiguous: T[] } | null {
const n = name.trim().toLowerCase()
if (!n) return null
const exact = items.filter((i) => nameOf(i).trim().toLowerCase() === n)
if (exact.length === 1) return { item: exact[0] }
if (exact.length > 1) return { ambiguous: exact }
const partial = items.filter((i) => nameOf(i).toLowerCase().includes(n))
if (partial.length === 1) return { item: partial[0] }
if (partial.length > 1) return { ambiguous: partial }
return null
}
@@ -633,7 +633,8 @@ export function applyToolCall(
const contentType = args.contentType
? String(args.contentType)
: undefined
if (typeof args.body !== "string") {
const body = args.body
if (body === undefined || body === null) {
if (!contentType) {
return {
handled: true,
@@ -652,10 +653,38 @@ export function applyToolCall(
reply: applyChatBody(req, current, contentType),
}
}
if (typeof body === "string") {
return {
handled: true,
changed: true,
reply: applyChatBody(req, body, contentType),
}
}
// Weaker models send a JSON body as an object: serialize it as JSON.
const type =
contentType && ALLOWED_BODY_TYPES.has(contentType) ? contentType : null
if (type && !/json/i.test(type)) {
return {
handled: true,
changed: false,
reply: `⚠️ A ${code(type)} body must be text — nothing changed.`,
}
}
// Keep a JSON variant (vnd.api+json…) the request already uses.
const current =
req.body && "contentType" in req.body ? req.body.contentType : null
const currentJSON =
current && ALLOWED_BODY_TYPES.has(current) && /json/i.test(current)
? current
: null
return {
handled: true,
changed: true,
reply: applyChatBody(req, args.body, contentType),
reply: applyChatBody(
req,
JSON.stringify(body, null, 2),
type ?? currentJSON ?? "application/json"
),
}
}
@@ -12,8 +12,13 @@ import {
* call — they are the largest pieces of context and rarely needed.
*/
export const truncateText = (value: string, max: number) =>
value.length > max ? `${value.slice(0, max)}…[truncated]` : value
/** Cuts at `max` without splitting an emoji into a lone surrogate. */
export const truncateText = (value: string, max: number) => {
if (value.length <= max) return value
const code = value.charCodeAt(max - 1)
const cut = code >= 0xd800 && code <= 0xdbff ? max - 1 : max
return `${value.slice(0, cut)}…[truncated]`
}
/**
* Compact SDL snapshot of an introspected schema. The operation roots
@@ -59,6 +64,9 @@ export const serializeGQLSchema = (
return parts.join("\n\n")
}
/** Deepest folder level the outline walks; deeper ones are counted, not listed. */
const MAX_DEPTH = 3
/** Compact outline of a collection tree — capped so the result stays bounded. */
export const serializeCollections = (
collections: HoppCollection[],
@@ -98,7 +106,15 @@ export const serializeCollections = (
return
}
}
if (depth < 3) walk(c.folders ?? [], depth + 1)
const folders = c.folders ?? []
if (depth < MAX_DEPTH) {
walk(folders, depth + 1)
} else if (folders.length) {
const n = folders.length
push(
`${" ".repeat(depth + 1)}- …${n} more folder${n > 1 ? "s" : ""} nested deeper`
)
}
}
}
@@ -18,6 +18,7 @@ export type StepKind =
| "properties"
| "tab"
| "closed"
| "deleted"
| "saved"
| "team"
| "personal"
@@ -55,6 +56,7 @@ const GLYPHS: Array<[string, StepKind]> = [
["📂", "tab"],
["🗂️", "tab"],
["🗙", "closed"],
["🗑️", "deleted"],
["💾", "saved"],
["👥", "team"],
["🏠", "personal"],
@@ -97,6 +99,7 @@ const TONES: Record<StepKind, StepTone> = {
properties: "neutral",
tab: "neutral",
closed: "neutral",
deleted: "neutral",
saved: "neutral",
team: "neutral",
personal: "neutral",
@@ -87,10 +87,15 @@ export type ExperimentsPlatformDef = {
}
>
>
/**
* One model round-trip. An aborted `signal` (the user pressed Stop)
* resolves to `E.left("ABORTED")`.
*/
chat?: (
messages: { role: "user" | "assistant"; content: string | unknown[] }[],
context: string,
selection?: AIChatSelection
selection?: AIChatSelection,
options?: { signal?: AbortSignal }
) => Promise<
E.Either<
string,
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { TestContainer } from "dioc/testing"
import * as E from "fp-ts/Either"
import { makeCollection } from "@hoppscotch/data"
// The sidebar helpers the delete reuses reach services through `getService`.
const holder = vi.hoisted(() => ({
container: null as null | { bind: (service: never) => unknown },
}))
vi.mock("~/modules/dioc", async () => {
const { Container } = await import("dioc")
const fallback = new Container()
return {
getService: (service: never) =>
(holder.container ?? fallback).bind(service),
}
})
vi.mock("~/modules/i18n", () => ({
getI18n: () => (key: string) => key,
APP_LANGUAGES: [],
changeAppLanguage: () => {},
}))
const ai = vi.hoisted(() => ({
chat: undefined as unknown,
getChatAvailability: undefined as unknown,
}))
vi.mock("~/platform", () => ({
platform: {
experiments: { aiExperiments: ai },
auth: {
getCurrentUser: () => null,
getCurrentUserStream: () => ({ subscribe: () => ({ unsubscribe() {} }) }),
},
},
}))
import { AIChatService } from "../ai-chat.service"
import { restCollectionStore, setRESTCollections } from "~/newstore/collections"
type ToolResult = { content: string; is_error?: boolean }
type Internals = {
turnGeneration: number
captureLocalSecret(secret: string): string
mockServersEnabledError(): string | null
loadMockServersForWorkspace(): Promise<unknown>
executeToolCalls(
calls: { id: string; name: string; input: Record<string, unknown> }[],
generation: number
): Promise<{ toolResults: ToolResult[] }>
}
const inner = (c: AIChatService) => c as unknown as Internals
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms))
const chatFn = vi.fn()
const getAvailability = vi.fn()
const on = (enabled: boolean) =>
E.right({ enabled, models: [], skills: [] }) as never
describe("AIChatService availability and errors", () => {
let chat: AIChatService
beforeEach(() => {
chatFn.mockReset()
getAvailability.mockReset()
ai.chat = chatFn
ai.getChatAvailability = getAvailability
const c = new TestContainer()
holder.container = c as never
chat = c.bind(AIChatService)
})
afterEach(() => {
holder.container = null
})
describe("available", () => {
it("is false until the server says the assistant is on", async () => {
expect(chat.available.value).toBe(false)
getAvailability.mockResolvedValueOnce(E.left("NETWORK"))
await chat.loadAvailability()
expect(chat.available.value).toBe(false)
getAvailability.mockResolvedValueOnce(on(true))
await chat.loadAvailability()
expect(chat.available.value).toBe(true)
})
it("forgets the answer when the session ends", async () => {
getAvailability.mockResolvedValueOnce(on(true))
await chat.loadAvailability()
chat.clearAvailability()
expect(chat.available.value).toBe(false)
expect(chat.availabilityKnown.value).toBe(false)
})
it("drops a lookup that outlived its session", async () => {
let answer!: (v: unknown) => void
getAvailability.mockReturnValueOnce(new Promise((r) => (answer = r)))
const lookup = chat.loadAvailability()
chat.clearAvailability()
answer(on(true))
await lookup
expect(chat.available.value).toBe(false)
})
it("follows chat alone on a platform with no server switch", () => {
ai.getChatAvailability = undefined
expect(chat.available.value).toBe(true)
})
})
describe("chat errors", () => {
const errorFor = async (code: string) => {
chatFn.mockResolvedValueOnce(E.left(code))
await chat.sendMessage("hi", "")
const last = chat.messages.value.at(-1)
expect(last?.kind).toBe("error")
return last?.content
}
it.each([
["PROVIDER_MISCONFIGURED", "provider_misconfigured"],
["PROVIDER_TIMEOUT", "provider_timeout"],
["NETWORK", "network"],
["RATE_LIMITED", "rate_limited"],
["UNAUTHORIZED", "unauthorized"],
["CHAT_DISABLED", "chat_disabled"],
["CANNOT_RUN_CHAT", "cannot_run_chat"],
["toString", "cannot_run_chat"],
])("maps %s to its own notice", async (code, key) => {
getAvailability.mockResolvedValue(on(true))
expect(await errorFor(code)).toBe(`⚠️ ai_experiments.chat.errors.${key}`)
})
it("re-reads availability when the server says the chat is off", async () => {
getAvailability.mockResolvedValueOnce(on(true))
await chat.loadAvailability()
getAvailability.mockResolvedValueOnce(on(false))
await errorFor("CHAT_DISABLED")
await tick()
expect(getAvailability).toHaveBeenCalledTimes(2)
expect(chat.available.value).toBe(false)
})
it("ends a stopped round-trip quietly", async () => {
chatFn.mockResolvedValueOnce(E.left("ABORTED"))
await chat.sendMessage("hi", "")
expect(chat.messages.value.some((m) => m.kind === "error")).toBe(false)
})
})
describe("maskLocalSecrets", () => {
it("puts a resolved credential back behind its reference", () => {
const id = inner(chat).captureLocalSecret("f9e8d7c6b5a43210")
const long = inner(chat).captureLocalSecret("f9e8d7c6b5a43210-extra")
expect(
chat.maskLocalSecrets(
"- X-Auth-Token: f9e8d7c6b5a43210\n?k=f9e8d7c6b5a43210-extra"
)
).toBe(`- X-Auth-Token: <<local-ref:${id}>>\n?k=<<local-ref:${long}>>`)
})
it("leaves very short values alone", () => {
inner(chat).captureLocalSecret("ab")
expect(chat.maskLocalSecrets("tab: abc")).toBe("tab: abc")
})
})
describe("successful replies go back as successes", () => {
const run = (name: string, input: Record<string, unknown> = {}) =>
inner(chat).executeToolCalls(
[{ id: "t1", name, input }],
inner(chat).turnGeneration
)
it("a confirmed delete", async () => {
setRESTCollections([
makeCollection({
name: "Payments",
folders: [],
requests: [],
auth: { authType: "inherit", authActive: true },
headers: [],
variables: [],
} as never),
])
const done = run("delete_collection", { collection: "Payments" })
for (let i = 0; i < 50 && !chat.pendingConfirmation.value; i++)
await tick()
chat.resolveConfirmation(true)
const [result] = (await done).toolResults
expect(restCollectionStore.value.state).toEqual([])
expect(result.content).toMatch(/Deleted \*\*Payments\*\*/)
expect(result.is_error).toBeUndefined()
})
// The model only saw the marker; writing it back loses the real value.
it("refuses input that carries a redacted value", async () => {
setRESTCollections([])
const [result] = (
await run("create_collection", { name: "Keys [REDACTED]" })
).toolResults
expect(restCollectionStore.value.state).toEqual([])
expect(result.content).toMatch(/redacted; ask the user/)
expect(result.is_error).toBe(true)
})
it("an empty mock server list", async () => {
inner(chat).mockServersEnabledError = () => null
inner(chat).loadMockServersForWorkspace = async () => ({ servers: [] })
const [result] = (await run("list_mock_servers")).toolResults
expect(result.content).toMatch(/no mock servers/)
expect(result.is_error).toBeUndefined()
})
})
})
@@ -152,7 +152,7 @@ describe("AIChatService teardown", () => {
E.right({ content: "one two three four five six", tool_calls: [] })
)
const turn = chat.sendMessage("hello", "")
await tick(12) // inside streamText's per-token delay
await tick(12) // inside streamText's first frame
chat.reset()
await turn
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,412 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { TestContainer } from "dioc/testing"
import * as E from "fp-ts/Either"
import { makeCollection } from "@hoppscotch/data"
const chatFn = vi.fn()
const mockServers = vi.fn()
// Same module-scope circularity the teardown spec works around.
vi.mock("~/modules/i18n", () => ({
getI18n: () => (key: string) => key,
APP_LANGUAGES: [],
changeAppLanguage: () => {},
}))
vi.mock("~/platform", () => ({
platform: {
experiments: { aiExperiments: { chat: (...a: unknown[]) => chatFn(...a) } },
auth: {
getCurrentUser: () => null,
getCurrentUserStream: () => ({ subscribe: () => ({ unsubscribe() {} }) }),
},
backend: {
getMyMockServers: () => () => mockServers(),
getTeamMockServers: () => () => mockServers(),
},
},
}))
import { AIChatService } from "../ai-chat.service"
import { WorkspaceTabsService } from "~/services/tab/workspace-tabs"
import { WorkspaceService } from "~/services/workspace.service"
import { TeamCollectionsService } from "~/services/team-collection.service"
import { TeamAccessRole } from "~/helpers/backend/graphql"
import { getDefaultRESTRequest } from "~/helpers/rest/default"
import { restCollectionStore, setRESTCollections } from "~/newstore/collections"
import { applySetting } from "~/newstore/settings"
import { bindAction, unbindAction } from "~/helpers/actions"
type ToolCall = { id: string; name: string; input: Record<string, unknown> }
type ToolResult = { content: string; is_error?: boolean }
type Internals = {
turnGeneration: number
executeToolCalls(
calls: ToolCall[],
generation: number
): Promise<{ replies: string[]; toolResults: ToolResult[] }>
}
const inner = (c: AIChatService) => c as unknown as Internals
const deferred = <T = unknown>() => {
let resolve!: (v: T) => void
const promise = new Promise<T>((r) => (resolve = r))
return { promise, resolve }
}
const tick = (ms = 0) => new Promise((r) => setTimeout(r, ms))
const reply = (tool_calls: ToolCall[], content = "") =>
E.right({ content, tool_calls, trace_id: "t" })
const collection = (name: string, requests: unknown[] = []) =>
makeCollection({
name,
folders: [],
requests: requests as never,
auth: { authType: "inherit", authActive: true },
headers: [],
variables: [],
description: null,
preRequestScript: "",
testScript: "",
})
const names = () => restCollectionStore.value.state.map((c) => c.name)
/** The tool results the model got back on the given (0-based) round-trip. */
const resultsSentOn = (call: number) => {
const messages = chatFn.mock.calls[call][0] as { content: ToolResult[] }[]
return messages[messages.length - 1].content
}
/** Stands in for pages/index.vue, the only page binding it. */
const openWorkspace = () => {}
const LONE_SURROGATE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/
describe("AIChatService turn lifecycle", () => {
let chat: AIChatService
let tabs: WorkspaceTabsService
let workspace: WorkspaceService
const enterTeam = (teamName = "Acme") =>
workspace.changeWorkspace({
type: "team",
teamID: "team_1",
teamName,
role: TeamAccessRole.Owner,
})
beforeEach(() => {
chatFn.mockReset()
mockServers.mockReset()
const c = new TestContainer()
chat = c.bind(AIChatService)
tabs = c.bind(WorkspaceTabsService)
workspace = c.bind(WorkspaceService)
// No backend here: skip the team tree load a switch triggers.
vi.spyOn(c.bind(TeamCollectionsService), "changeTeamID").mockImplementation(
() => {}
)
setRESTCollections([collection("Staging")])
bindAction("rest.request.open", openWorkspace)
})
afterEach(() => {
unbindAction("rest.request.open", openWorkspace)
vi.useRealTimers()
vi.restoreAllMocks()
})
describe("context per step", () => {
it("re-reads the context for the pinned tab before every step", async () => {
setRESTCollections([
collection("API", [
{
...getDefaultRESTRequest(),
name: "Create User",
method: "POST",
endpoint: "https://api.example/users",
},
]),
])
const start = tabs.currentActiveTab.value.id
const context = vi.fn((tabId: string | null) => `ctx:${tabId}`)
chatFn
.mockResolvedValueOnce(
reply([
{
id: "1",
name: "open_request",
input: { request: "Create User" },
},
])
)
.mockResolvedValueOnce(reply([], "done"))
await chat.sendMessage("open Create User", context)
const opened = tabs.currentActiveTab.value.id
expect(opened).not.toBe(start)
expect(chatFn.mock.calls.map((c) => c[1])).toEqual([
`ctx:${start}`,
`ctx:${opened}`,
])
// The model also learns what opened without waiting a step.
expect(resultsSentOn(1)[0].content).toContain(
"`POST https://api.example/users`"
)
})
})
describe("workspace pin", () => {
it("refuses a workspace tool after the user switched mid-reply", async () => {
enterTeam()
const round = deferred()
chatFn
.mockReturnValueOnce(round.promise)
.mockResolvedValueOnce(reply([], "done"))
const turn = chat.sendMessage("delete the Staging collection", "")
await Promise.resolve()
workspace.changeWorkspace({ type: "personal" })
round.resolve(
reply([
{
id: "1",
name: "delete_collection",
input: { collection: "Staging" },
},
])
)
await turn
expect(chat.pendingConfirmation.value).toBeNull()
expect(names()).toEqual(["Staging"])
const [result] = resultsSentOn(1)
expect(result.content).toMatch(/workspace changed/)
expect(result.is_error).toBe(true)
})
it("follows a switch the turn made itself", async () => {
enterTeam()
chatFn
.mockResolvedValueOnce(
reply([
{
id: "1",
name: "switch_workspace",
input: { workspace: "personal" },
},
{ id: "2", name: "create_collection", input: { name: "New" } },
])
)
.mockResolvedValueOnce(reply([], "done"))
await chat.sendMessage("go personal and add a New collection", "")
expect(names()).toEqual(["Staging", "New"])
})
it("names the workspace in a delete confirmation", async () => {
applySetting("ENABLE_EXPERIMENTAL_MOCK_SERVERS", true)
enterTeam("Acme")
mockServers.mockResolvedValue(
E.right([{ id: "m1", name: "Orders Mock" }])
)
const done = inner(chat).executeToolCalls(
[{ id: "1", name: "delete_mock_server", input: { name: "Orders" } }],
inner(chat).turnGeneration
)
while (!chat.pendingConfirmation.value) await tick(5)
expect(chat.pendingConfirmation.value).toMatchObject({
kind: "mock-server",
name: "Orders Mock",
workspace: "Acme",
})
chat.resolveConfirmation(false)
await done
})
})
describe("stop", () => {
it("aborts the round-trip and frees the composer", async () => {
const round = deferred()
chatFn.mockReturnValueOnce(round.promise)
const turn = chat.sendMessage("set the method to POST", "")
await Promise.resolve()
const signal = chatFn.mock.calls[0][3]?.signal as AbortSignal
chat.stop()
expect(signal.aborted).toBe(true)
expect(chat.isStreaming.value).toBe(false)
expect(chat.messages.value.some((m) => m.pending)).toBe(false)
expect(chat.messages.value.at(-1)).toMatchObject({
kind: "tool",
content: "■ Stopped.",
})
// The platform's late answer is discarded.
round.resolve(
reply([{ id: "1", name: "set_method", input: { method: "POST" } }])
)
await turn
expect(chat.lastTurnTools.value).toEqual([])
})
it("declines an open prompt and runs nothing after it", async () => {
const method = () => {
const doc = tabs.currentActiveTab.value.document
return doc.type === "request" ? doc.request.method : null
}
chatFn.mockResolvedValueOnce(
reply([
{
id: "1",
name: "delete_collection",
input: { collection: "Staging" },
},
{ id: "2", name: "set_method", input: { method: "DELETE" } },
])
)
const turn = chat.sendMessage("drop Staging, make it a DELETE", "")
while (!chat.pendingConfirmation.value) await tick(5)
chat.stop()
await turn
expect(chat.pendingConfirmation.value).toBeNull()
expect(names()).toEqual(["Staging"])
expect(method()).toBe("GET")
// A new message goes through straight away.
chatFn.mockResolvedValueOnce(reply([], "hi"))
await chat.sendMessage("hello", "")
expect(chat.messages.value.at(-1)?.content).toBe("hi")
})
})
describe("an abandoned turn's prompts", () => {
const deleteMockServerTurn = async () => {
applySetting("ENABLE_EXPERIMENTAL_MOCK_SERVERS", true)
const servers = deferred()
mockServers.mockReturnValueOnce(servers.promise)
chatFn.mockResolvedValueOnce(
reply([{ id: "1", name: "delete_mock_server", input: { name: "M" } }])
)
const turn = chat.sendMessage("delete mock server M", "")
while (!mockServers.mock.calls.length) await tick(5)
return { turn, servers }
}
it("never opens after a reset", async () => {
const { turn, servers } = await deleteMockServerTurn()
chat.reset()
servers.resolve(E.right([{ id: "m1", name: "M" }]))
await turn
expect(chat.pendingConfirmation.value).toBeNull()
})
it("does not close the live turn's prompt", async () => {
const { turn, servers } = await deleteMockServerTurn()
chat.stop()
chatFn.mockResolvedValueOnce(
reply([
{
id: "2",
name: "delete_collection",
input: { collection: "Staging" },
},
])
)
const live = chat.sendMessage("drop Staging", "")
while (!chat.pendingConfirmation.value) await tick(5)
servers.resolve(E.right([{ id: "m1", name: "M" }]))
await turn
expect(chat.pendingConfirmation.value).toMatchObject({
kind: "collection",
name: "Staging",
})
chat.resolveConfirmation(false)
chatFn.mockResolvedValueOnce(reply([], "ok"))
await live
})
})
describe("typing", () => {
it("does not hold up the loop for a long reply", async () => {
chatFn.mockResolvedValueOnce(
reply([], Array.from({ length: 400 }, () => "word").join(" "))
)
const started = Date.now()
await chat.sendMessage("write a lot", "")
expect(Date.now() - started).toBeLessThan(1_000)
expect(chat.messages.value.at(-1)?.content).toHaveLength(400 * 5 - 1)
})
it("shows the reply at once in a hidden tab", async () => {
vi.useFakeTimers()
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => true,
})
try {
chatFn.mockResolvedValueOnce(reply([], "one two three"))
let done = false
void chat.sendMessage("hi", "").then(() => (done = true))
// Flush promises only: no timer may be needed to finish.
for (let i = 0; i < 50; i++) await Promise.resolve()
expect(done).toBe(true)
expect(chat.messages.value.at(-1)?.content).toBe("one two three")
} finally {
delete (document as { hidden?: boolean }).hidden
}
})
})
describe("surrogate-safe truncation", () => {
it("never splits an emoji in an older history message", async () => {
const long = `${"x".repeat(799)}😀${"y".repeat(100)}`
chatFn
.mockResolvedValueOnce(reply([], long))
.mockResolvedValueOnce(reply([], "ok"))
await chat.sendMessage("first", "")
await chat.sendMessage("second", "")
const history = chatFn.mock.calls[1][0] as { content: string }[]
const older = history.find((m) => m.content.startsWith("xxx"))
expect(older?.content.length).toBeLessThan(long.length + 200)
for (const m of history)
expect(LONE_SURROGATE.test(m.content)).toBe(false)
})
it("never splits an emoji in a capped tool result", async () => {
// One of two adjacent offsets always lands mid-pair at the cap.
for (const pad of [780, 781]) {
const { toolResults } = await inner(chat).executeToolCalls(
[
{
id: "1",
name: `${"a".repeat(pad)}${"😀".repeat(30)}`,
input: {},
},
],
inner(chat).turnGeneration
)
expect(toolResults[0].content.length).toBeLessThanOrEqual(801)
expect(LONE_SURROGATE.test(toolResults[0].content)).toBe(false)
}
})
})
})
File diff suppressed because it is too large Load Diff