mirror of
https://github.com/hoppscotch/hoppscotch.git
synced 2026-10-24 12:11:35 +08:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
Reference in New Issue
Block a user