Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
@@ -29,6 +29,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
|
||||
import { Log } from "../util/log"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { ACPSessionManager } from "./session"
|
||||
import type { ACPConfig } from "./types"
|
||||
import { Provider } from "../provider/provider"
|
||||
@@ -986,7 +987,7 @@ export namespace ACP {
|
||||
type: "image",
|
||||
mimeType: effectiveMime,
|
||||
data: base64Data,
|
||||
uri: `file://${filename}`,
|
||||
uri: pathToFileURL(filename).href,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -996,13 +997,14 @@ export namespace ACP {
|
||||
} else {
|
||||
// Non-image: text types get decoded, binary types stay as blob
|
||||
const isText = effectiveMime.startsWith("text/") || effectiveMime === "application/json"
|
||||
const fileUri = pathToFileURL(filename).href
|
||||
const resource = isText
|
||||
? {
|
||||
uri: `file://${filename}`,
|
||||
uri: fileUri,
|
||||
mimeType: effectiveMime,
|
||||
text: Buffer.from(base64Data, "base64").toString("utf-8"),
|
||||
}
|
||||
: { uri: `file://${filename}`, mimeType: effectiveMime, blob: base64Data }
|
||||
: { uri: fileUri, mimeType: effectiveMime, blob: base64Data }
|
||||
|
||||
await this.connection
|
||||
.sessionUpdate({
|
||||
@@ -1544,7 +1546,7 @@ export namespace ACP {
|
||||
const name = path.split("/").pop() || path
|
||||
return {
|
||||
type: "file",
|
||||
url: `file://${path}`,
|
||||
url: pathToFileURL(path).href,
|
||||
filename: name,
|
||||
mime: "text/plain",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Argv } from "yargs"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "bun"
|
||||
import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
@@ -236,6 +237,10 @@ export const RunCommand = cmd({
|
||||
describe: "session id to continue",
|
||||
type: "string",
|
||||
})
|
||||
.option("fork", {
|
||||
describe: "fork the session before continuing (requires --continue or --session)",
|
||||
type: "boolean",
|
||||
})
|
||||
.option("share", {
|
||||
type: "boolean",
|
||||
describe: "share the session",
|
||||
@@ -310,7 +315,7 @@ export const RunCommand = cmd({
|
||||
|
||||
files.push({
|
||||
type: "file",
|
||||
url: `file://${resolvedPath}`,
|
||||
url: pathToFileURL(resolvedPath).href,
|
||||
filename: path.basename(resolvedPath),
|
||||
mime,
|
||||
})
|
||||
@@ -324,6 +329,11 @@ export const RunCommand = cmd({
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const rules: PermissionNext.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
@@ -349,11 +359,15 @@ export const RunCommand = cmd({
|
||||
}
|
||||
|
||||
async function session(sdk: OpencodeClient) {
|
||||
if (args.continue) {
|
||||
const result = await sdk.session.list()
|
||||
return result.data?.find((s) => !s.parentID)?.id
|
||||
const baseID = args.continue ? (await sdk.session.list()).data?.find((s) => !s.parentID)?.id : args.session
|
||||
|
||||
if (baseID && args.fork) {
|
||||
const forked = await sdk.session.fork({ sessionID: baseID })
|
||||
return forked.data?.id
|
||||
}
|
||||
if (args.session) return args.session
|
||||
|
||||
if (baseID) return baseID
|
||||
|
||||
const name = title()
|
||||
const result = await sdk.session.create({ title: name, permission: rules })
|
||||
return result.data?.id
|
||||
|
||||
@@ -250,7 +250,8 @@ function App() {
|
||||
})
|
||||
local.model.set({ providerID, modelID }, { recent: true })
|
||||
}
|
||||
if (args.sessionID) {
|
||||
// Handle --session without --fork immediately (fork is handled in createEffect below)
|
||||
if (args.sessionID && !args.fork) {
|
||||
route.navigate({
|
||||
type: "session",
|
||||
sessionID: args.sessionID,
|
||||
@@ -268,10 +269,36 @@ function App() {
|
||||
.find((x) => x.parentID === undefined)?.id
|
||||
if (match) {
|
||||
continued = true
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
if (args.fork) {
|
||||
sdk.client.session.fork({ sessionID: match }).then((result) => {
|
||||
if (result.data?.id) {
|
||||
route.navigate({ type: "session", sessionID: result.data.id })
|
||||
} else {
|
||||
toast.show({ message: "Failed to fork session", variant: "error" })
|
||||
}
|
||||
})
|
||||
} else {
|
||||
route.navigate({ type: "session", sessionID: match })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Handle --session with --fork: wait for sync to be fully complete before forking
|
||||
// (session list loads in non-blocking phase for --session, so we must wait for "complete"
|
||||
// to avoid a race where reconcile overwrites the newly forked session)
|
||||
let forked = false
|
||||
createEffect(() => {
|
||||
if (forked || sync.status !== "complete" || !args.sessionID || !args.fork) return
|
||||
forked = true
|
||||
sdk.client.session.fork({ sessionID: args.sessionID }).then((result) => {
|
||||
if (result.data?.id) {
|
||||
route.navigate({ type: "session", sessionID: result.data.id })
|
||||
} else {
|
||||
toast.show({ message: "Failed to fork session", variant: "error" })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.status === "complete" && sync.data.provider.length === 0,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TextAttributes } from "@opentui/core"
|
||||
import { fileURLToPath } from "bun"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useDialog } from "@tui/ui/dialog"
|
||||
import { useSync } from "@tui/context/sync"
|
||||
@@ -19,7 +20,7 @@ export function DialogStatus() {
|
||||
const list = sync.data.config.plugin ?? []
|
||||
const result = list.map((value) => {
|
||||
if (value.startsWith("file://")) {
|
||||
const path = value.substring("file://".length)
|
||||
const path = fileURLToPath(value)
|
||||
const parts = path.split("/")
|
||||
const filename = parts.pop() || path
|
||||
if (!filename.includes(".")) return { name: filename }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { BoxRenderable, TextareaRenderable, KeyEvent, ScrollBoxRenderable } from "@opentui/core"
|
||||
import { pathToFileURL } from "bun"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { firstBy } from "remeda"
|
||||
import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Show, createSignal } from "solid-js"
|
||||
@@ -246,17 +247,17 @@ export function Autocomplete(props: {
|
||||
const width = props.anchor().width - 4
|
||||
options.push(
|
||||
...sortedFiles.map((item): AutocompleteOption => {
|
||||
let url = `file://${process.cwd()}/${item}`
|
||||
const fullPath = `${process.cwd()}/${item}`
|
||||
const urlObj = pathToFileURL(fullPath)
|
||||
let filename = item
|
||||
if (lineRange && !item.endsWith("/")) {
|
||||
filename = `${item}#${lineRange.startLine}${lineRange.endLine ? `-${lineRange.endLine}` : ""}`
|
||||
const urlObj = new URL(url)
|
||||
urlObj.searchParams.set("start", String(lineRange.startLine))
|
||||
if (lineRange.endLine !== undefined) {
|
||||
urlObj.searchParams.set("end", String(lineRange.endLine))
|
||||
}
|
||||
url = urlObj.toString()
|
||||
}
|
||||
const url = urlObj.href
|
||||
|
||||
const isDir = item.endsWith("/")
|
||||
return {
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Args {
|
||||
prompt?: string
|
||||
continue?: boolean
|
||||
sessionID?: string
|
||||
fork?: boolean
|
||||
}
|
||||
|
||||
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
|
||||
|
||||
@@ -64,6 +64,10 @@ export const TuiThreadCommand = cmd({
|
||||
type: "string",
|
||||
describe: "session id to continue",
|
||||
})
|
||||
.option("fork", {
|
||||
type: "boolean",
|
||||
describe: "fork the session when continuing (use with --continue or --session)",
|
||||
})
|
||||
.option("prompt", {
|
||||
type: "string",
|
||||
describe: "prompt to use",
|
||||
@@ -73,6 +77,11 @@ export const TuiThreadCommand = cmd({
|
||||
describe: "agent to use",
|
||||
}),
|
||||
handler: async (args) => {
|
||||
if (args.fork && !args.continue && !args.session) {
|
||||
UI.error("--fork requires --continue or --session")
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Resolve relative paths against PWD to preserve behavior when using --cwd flag
|
||||
const baseCwd = process.env.PWD ?? process.cwd()
|
||||
const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
|
||||
@@ -150,6 +159,7 @@ export const TuiThreadCommand = cmd({
|
||||
agent: args.agent,
|
||||
model: args.model,
|
||||
prompt,
|
||||
fork: args.fork,
|
||||
},
|
||||
onExit: async () => {
|
||||
await client.call("shutdown", undefined)
|
||||
|
||||
@@ -33,6 +33,8 @@ import { proxied } from "@/util/proxied"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
|
||||
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
|
||||
@@ -653,7 +655,7 @@ export namespace Config {
|
||||
template: z.string(),
|
||||
description: z.string().optional(),
|
||||
agent: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
model: ModelId.optional(),
|
||||
subtask: z.boolean().optional(),
|
||||
})
|
||||
export type Command = z.infer<typeof Command>
|
||||
@@ -669,7 +671,7 @@ export namespace Config {
|
||||
|
||||
export const Agent = z
|
||||
.object({
|
||||
model: z.string().optional(),
|
||||
model: ModelId.optional(),
|
||||
variant: z
|
||||
.string()
|
||||
.optional()
|
||||
@@ -1040,11 +1042,10 @@ export namespace Config {
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe("When set, ONLY these providers will be enabled. All other providers will be ignored"),
|
||||
model: z.string().describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
small_model: z
|
||||
.string()
|
||||
.describe("Small model to use for tasks like title generation in the format of provider/model")
|
||||
.optional(),
|
||||
model: ModelId.describe("Model to use in the format of provider/model, eg anthropic/claude-2").optional(),
|
||||
small_model: ModelId.describe(
|
||||
"Small model to use for tasks like title generation in the format of provider/model",
|
||||
).optional(),
|
||||
default_agent: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Bus } from "@/bus"
|
||||
import { Log } from "../util/log"
|
||||
import { LSPClient } from "./client"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { LSPServer } from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "../config/config"
|
||||
@@ -369,7 +369,7 @@ export namespace LSP {
|
||||
}
|
||||
|
||||
export async function documentSymbol(uri: string) {
|
||||
const file = new URL(uri).pathname
|
||||
const file = fileURLToPath(uri)
|
||||
return run(file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/documentSymbol", {
|
||||
|
||||
@@ -646,6 +646,7 @@ export namespace ProviderTransform {
|
||||
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
|
||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||
result["reasoningEffort"] = "medium"
|
||||
result["reasoningSummary"] = "auto"
|
||||
}
|
||||
|
||||
// Only set textVerbosity for non-chat gpt-5.x models
|
||||
|
||||
@@ -539,7 +539,7 @@ export const SessionRoutes = lazy(() =>
|
||||
},
|
||||
auto: body.auto,
|
||||
})
|
||||
await SessionPrompt.loop(sessionID)
|
||||
await SessionPrompt.loop({ sessionID })
|
||||
return c.json(true)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -108,6 +108,7 @@ export namespace SessionCompaction {
|
||||
sessionID: input.sessionID,
|
||||
mode: "compaction",
|
||||
agent: "compaction",
|
||||
variant: userMessage.variant,
|
||||
summary: true,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
|
||||
@@ -387,6 +387,7 @@ export namespace MessageV2 {
|
||||
write: z.number(),
|
||||
}),
|
||||
}),
|
||||
variant: z.string().optional(),
|
||||
finish: z.string().optional(),
|
||||
}).meta({
|
||||
ref: "AssistantMessage",
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { Command } from "../command"
|
||||
import { $, fileURLToPath } from "bun"
|
||||
import { $, fileURLToPath, pathToFileURL } from "bun"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
import { SessionSummary } from "./summary"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
@@ -174,7 +174,7 @@ export namespace SessionPrompt {
|
||||
return message
|
||||
}
|
||||
|
||||
return loop(input.sessionID)
|
||||
return loop({ sessionID: input.sessionID })
|
||||
})
|
||||
|
||||
export async function resolvePromptParts(template: string): Promise<PromptInput["parts"]> {
|
||||
@@ -210,7 +210,7 @@ export namespace SessionPrompt {
|
||||
if (stats.isDirectory()) {
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `file://${filepath}`,
|
||||
url: pathToFileURL(filepath).href,
|
||||
filename: name,
|
||||
mime: "application/x-directory",
|
||||
})
|
||||
@@ -219,7 +219,7 @@ export namespace SessionPrompt {
|
||||
|
||||
parts.push({
|
||||
type: "file",
|
||||
url: `file://${filepath}`,
|
||||
url: pathToFileURL(filepath).href,
|
||||
filename: name,
|
||||
mime: "text/plain",
|
||||
})
|
||||
@@ -239,6 +239,13 @@ export namespace SessionPrompt {
|
||||
return controller.signal
|
||||
}
|
||||
|
||||
function resume(sessionID: string) {
|
||||
const s = state()
|
||||
if (!s[sessionID]) return
|
||||
|
||||
return s[sessionID].abort.signal
|
||||
}
|
||||
|
||||
export function cancel(sessionID: string) {
|
||||
log.info("cancel", { sessionID })
|
||||
const s = state()
|
||||
@@ -253,8 +260,14 @@ export namespace SessionPrompt {
|
||||
return
|
||||
}
|
||||
|
||||
export const loop = fn(Identifier.schema("session"), async (sessionID) => {
|
||||
const abort = start(sessionID)
|
||||
export const LoopInput = z.object({
|
||||
sessionID: Identifier.schema("session"),
|
||||
resume_existing: z.boolean().optional(),
|
||||
})
|
||||
export const loop = fn(LoopInput, async (input) => {
|
||||
const { sessionID, resume_existing } = input
|
||||
|
||||
const abort = resume_existing ? resume(sessionID) : start(sessionID)
|
||||
if (!abort) {
|
||||
return new Promise<MessageV2.WithParts>((resolve, reject) => {
|
||||
const callbacks = state()[sessionID].callbacks
|
||||
@@ -323,6 +336,7 @@ export namespace SessionPrompt {
|
||||
sessionID,
|
||||
mode: task.agent,
|
||||
agent: task.agent,
|
||||
variant: lastUser.variant,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
@@ -526,6 +540,7 @@ export namespace SessionPrompt {
|
||||
role: "assistant",
|
||||
mode: agent.name,
|
||||
agent: agent.name,
|
||||
variant: lastUser.variant,
|
||||
path: {
|
||||
cwd: Instance.directory,
|
||||
root: Instance.worktree,
|
||||
@@ -1366,7 +1381,19 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
if (!abort) {
|
||||
throw new Session.BusyError(input.sessionID)
|
||||
}
|
||||
using _ = defer(() => cancel(input.sessionID))
|
||||
|
||||
using _ = defer(() => {
|
||||
// If no queued callbacks, cancel (the default)
|
||||
const callbacks = state()[input.sessionID]?.callbacks ?? []
|
||||
if (callbacks.length === 0) {
|
||||
cancel(input.sessionID)
|
||||
} else {
|
||||
// Otherwise, trigger the session loop to process queued items
|
||||
loop({ sessionID: input.sessionID, resume_existing: true }).catch((error) => {
|
||||
log.error("session loop failed to resume after shell command", { sessionID: input.sessionID, error })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const session = await Session.get(input.sessionID)
|
||||
if (session.revert) {
|
||||
|
||||
@@ -411,8 +411,13 @@ export namespace Worktree {
|
||||
if (key === directory) return item
|
||||
}
|
||||
})()
|
||||
|
||||
if (!entry?.path) {
|
||||
throw new RemoveFailedError({ message: "Worktree not found" })
|
||||
const directoryExists = await exists(directory)
|
||||
if (directoryExists) {
|
||||
await fs.rm(directory, { recursive: true, force: true })
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import path from "path"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "url"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { Session } from "../../src/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
describe("session.prompt special characters", () => {
|
||||
test("handles filenames with # character", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "file#name.txt"), "special content\n")
|
||||
},
|
||||
})
|
||||
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const template = "Read @file#name.txt"
|
||||
const parts = await SessionPrompt.resolvePromptParts(template)
|
||||
const fileParts = parts.filter((part) => part.type === "file")
|
||||
|
||||
expect(fileParts.length).toBe(1)
|
||||
expect(fileParts[0].filename).toBe("file#name.txt")
|
||||
|
||||
// Verify the URL is properly encoded (# should be %23)
|
||||
expect(fileParts[0].url).toContain("%23")
|
||||
|
||||
// Verify the URL can be correctly converted back to a file path
|
||||
const decodedPath = fileURLToPath(fileParts[0].url)
|
||||
expect(decodedPath).toBe(path.join(tmp.path, "file#name.txt"))
|
||||
|
||||
const message = await SessionPrompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts,
|
||||
noReply: true,
|
||||
})
|
||||
const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
|
||||
|
||||
// Verify the file content was read correctly
|
||||
const textParts = stored.parts.filter((part) => part.type === "text")
|
||||
const hasContent = textParts.some((part) => part.text.includes("special content"))
|
||||
expect(hasContent).toBe(true)
|
||||
|
||||
await Session.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import path from "path"
|
||||
|
||||
const CLOUDFLARE_SKILLS_URL = "https://developers.cloudflare.com/.well-known/skills/"
|
||||
|
||||
describe("Discovery.pull", () => {
|
||||
test("downloads skills from cloudflare url", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
expect(dir).toStartWith(Discovery.dir())
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(await Bun.file(md).exists()).toBe(true)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("url without trailing slash works", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(await Bun.file(md).exists()).toBe(true)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("returns empty array for invalid url", async () => {
|
||||
const dirs = await Discovery.pull("https://example.invalid/.well-known/skills/")
|
||||
expect(dirs).toEqual([])
|
||||
})
|
||||
|
||||
test("returns empty array for non-json response", async () => {
|
||||
const dirs = await Discovery.pull("https://example.com/")
|
||||
expect(dirs).toEqual([])
|
||||
})
|
||||
|
||||
test("downloads reference files alongside SKILL.md", async () => {
|
||||
const dirs = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
// find a skill dir that should have reference files (e.g. agents-sdk)
|
||||
const agentsSdk = dirs.find((d) => d.endsWith("/agents-sdk"))
|
||||
if (agentsSdk) {
|
||||
const refs = path.join(agentsSdk, "references")
|
||||
expect(await Bun.file(path.join(agentsSdk, "SKILL.md")).exists()).toBe(true)
|
||||
// agents-sdk has reference files per the index
|
||||
const refDir = await Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true }))
|
||||
expect(refDir.length).toBeGreaterThan(0)
|
||||
}
|
||||
}, 30_000)
|
||||
|
||||
test("caches downloaded files on second pull", async () => {
|
||||
// first pull to populate cache
|
||||
const first = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
|
||||
// second pull should return same results from cache
|
||||
const second = await Discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(second.length).toBe(first.length)
|
||||
expect(second.sort()).toEqual(first.sort())
|
||||
}, 60_000)
|
||||
})
|
||||
Reference in New Issue
Block a user