test(e2e): isolate prompt tests with per-worker backend (#20464)
parent
d58004a864
commit
38d2276592
|
|
@ -312,10 +312,11 @@ export async function openSettings(page: Page) {
|
||||||
return dialog
|
return dialog
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
|
export async function seedProjects(page: Page, input: { directory: string; extra?: string[]; serverUrl?: string }) {
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||||
const key = "opencode.global.dat:server"
|
const key = "opencode.global.dat:server"
|
||||||
|
const defaultKey = "opencode.settings.dat:defaultServerUrl"
|
||||||
const raw = localStorage.getItem(key)
|
const raw = localStorage.getItem(key)
|
||||||
const parsed = (() => {
|
const parsed = (() => {
|
||||||
if (!raw) return undefined
|
if (!raw) return undefined
|
||||||
|
|
@ -331,6 +332,7 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||||
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||||
|
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||||
|
|
||||||
const add = (origin: string, directory: string) => {
|
const add = (origin: string, directory: string) => {
|
||||||
const current = nextProjects[origin]
|
const current = nextProjects[origin]
|
||||||
|
|
@ -356,17 +358,18 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
key,
|
key,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
list,
|
list: nextList,
|
||||||
projects: nextProjects,
|
projects: nextProjects,
|
||||||
lastProject,
|
lastProject,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
localStorage.setItem(defaultKey, args.serverUrl)
|
||||||
},
|
},
|
||||||
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
|
{ directory: input.directory, serverUrl: input.serverUrl ?? serverUrl, extra: input.extra ?? [] },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createTestProject() {
|
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
const id = `e2e-${path.basename(root)}`
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
|
|
@ -381,7 +384,7 @@ export async function createTestProject() {
|
||||||
stdio: "ignore",
|
stdio: "ignore",
|
||||||
})
|
})
|
||||||
|
|
||||||
return resolveDirectory(root)
|
return resolveDirectory(root, input?.serverUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanupTestProject(directory: string) {
|
export async function cleanupTestProject(directory: string) {
|
||||||
|
|
@ -430,22 +433,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveSlug(slug: string) {
|
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
||||||
const directory = base64Decode(slug)
|
const directory = base64Decode(slug)
|
||||||
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
const resolved = await resolveDirectory(directory)
|
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
||||||
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitDir(page: Page, directory: string) {
|
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
||||||
const target = await resolveDirectory(directory)
|
const target = await resolveDirectory(directory, input?.serverUrl)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitDir")
|
await assertHealthy(page, "waitDir")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return ""
|
if (!slug) return ""
|
||||||
return resolveSlug(slug)
|
return resolveSlug(slug, input)
|
||||||
.then((item) => item.directory)
|
.then((item) => item.directory)
|
||||||
.catch(() => "")
|
.catch(() => "")
|
||||||
},
|
},
|
||||||
|
|
@ -455,15 +458,15 @@ export async function waitDir(page: Page, directory: string) {
|
||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
|
||||||
const target = await resolveDirectory(input.directory)
|
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
async () => {
|
async () => {
|
||||||
await assertHealthy(page, "waitSession")
|
await assertHealthy(page, "waitSession")
|
||||||
const slug = slugFromUrl(page.url())
|
const slug = slugFromUrl(page.url())
|
||||||
if (!slug) return false
|
if (!slug) return false
|
||||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
||||||
if (!resolved || resolved.directory !== target) return false
|
if (!resolved || resolved.directory !== target) return false
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (input.sessionID && current !== input.sessionID) return false
|
if (input.sessionID && current !== input.sessionID) return false
|
||||||
|
|
@ -473,7 +476,7 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
if (!input.sessionID && state?.sessionID) return false
|
if (!input.sessionID && state?.sessionID) return false
|
||||||
if (state?.dir) {
|
if (state?.dir) {
|
||||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||||
if (dir !== target) return false
|
if (dir !== target) return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -489,9 +492,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||||
return { directory: target, slug: base64Encode(target) }
|
return { directory: target, slug: base64Encode(target) }
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
||||||
const sdk = createSdk(directory)
|
const sdk = createSdk(directory, serverUrl)
|
||||||
const target = await resolveDirectory(directory)
|
const target = await resolveDirectory(directory, serverUrl)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(
|
||||||
|
|
@ -501,7 +504,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
||||||
.then((x) => x.data)
|
.then((x) => x.data)
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
if (!data?.directory) return ""
|
if (!data?.directory) return ""
|
||||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
||||||
},
|
},
|
||||||
{ timeout },
|
{ timeout },
|
||||||
)
|
)
|
||||||
|
|
@ -666,8 +669,9 @@ export async function cleanupSession(input: {
|
||||||
sessionID: string
|
sessionID: string
|
||||||
directory?: string
|
directory?: string
|
||||||
sdk?: ReturnType<typeof createSdk>
|
sdk?: ReturnType<typeof createSdk>
|
||||||
|
serverUrl?: string
|
||||||
}) {
|
}) {
|
||||||
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory) : undefined)
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
||||||
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
|
@ -1019,3 +1023,13 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
await expect(menu).toBeVisible()
|
await expect(menu).toBeVisible()
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,119 @@
|
||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import net from "node:net"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
type Handle = {
|
||||||
|
url: string
|
||||||
|
stop: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once("error", reject)
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to acquire a free port")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve(address.port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(url: string, probe = "/global/health") {
|
||||||
|
const end = Date.now() + 120_000
|
||||||
|
let last = ""
|
||||||
|
while (Date.now() < end) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}${probe}`)
|
||||||
|
if (res.ok) return
|
||||||
|
last = `status ${res.status}`
|
||||||
|
} catch (err) {
|
||||||
|
last = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_CAP = 100
|
||||||
|
|
||||||
|
function cap(input: string[]) {
|
||||||
|
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(input: string[]) {
|
||||||
|
return input.slice(-40).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBackend(label: string): Promise<Handle> {
|
||||||
|
const port = await freePort()
|
||||||
|
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||||
|
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const repoDir = path.resolve(appDir, "../..")
|
||||||
|
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||||
|
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
||||||
|
XDG_DATA_HOME: path.join(sandbox, "share"),
|
||||||
|
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
||||||
|
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
||||||
|
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||||
|
OPENCODE_CLIENT: "app",
|
||||||
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
|
} satisfies Record<string, string | undefined>
|
||||||
|
const out: string[] = []
|
||||||
|
const err: string[] = []
|
||||||
|
const proc = spawn(
|
||||||
|
"bun",
|
||||||
|
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
||||||
|
{
|
||||||
|
cwd: opencodeDir,
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
proc.stdout?.on("data", (chunk) => { out.push(String(chunk)); cap(out) })
|
||||||
|
proc.stderr?.on("data", (chunk) => { err.push(String(chunk)); cap(err) })
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${port}`
|
||||||
|
try {
|
||||||
|
await waitForHealth(url)
|
||||||
|
} catch (error) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to start isolated e2e backend for ${label}`,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
tail(out),
|
||||||
|
tail(err),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
async stop() {
|
||||||
|
if (proc.exitCode === null) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined)
|
||||||
|
}
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import { ManagedRuntime } from "effect"
|
||||||
import type { E2EWindow } from "../src/testing/terminal"
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
||||||
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
||||||
|
import { startBackend } from "./backend"
|
||||||
import {
|
import {
|
||||||
healthPhase,
|
healthPhase,
|
||||||
cleanupSession,
|
cleanupSession,
|
||||||
|
|
@ -19,6 +20,20 @@ import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||||
type LLMFixture = {
|
type LLMFixture = {
|
||||||
url: string
|
url: string
|
||||||
push: (...input: (Item | Reply)[]) => Promise<void>
|
push: (...input: (Item | Reply)[]) => Promise<void>
|
||||||
|
pushMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
...input: (Item | Reply)[]
|
||||||
|
) => Promise<void>
|
||||||
|
textMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
value: string,
|
||||||
|
opts?: { usage?: Usage },
|
||||||
|
) => Promise<void>
|
||||||
|
toolMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
name: string,
|
||||||
|
input: unknown,
|
||||||
|
) => Promise<void>
|
||||||
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||||
tool: (name: string, input: unknown) => Promise<void>
|
tool: (name: string, input: unknown) => Promise<void>
|
||||||
toolHang: (name: string, input: unknown) => Promise<void>
|
toolHang: (name: string, input: unknown) => Promise<void>
|
||||||
|
|
@ -46,32 +61,54 @@ const seedModel = (() => {
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
|
|
||||||
type TestFixtures = {
|
type ProjectHandle = {
|
||||||
llm: LLMFixture
|
|
||||||
sdk: ReturnType<typeof createSdk>
|
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
|
||||||
withProject: <T>(
|
|
||||||
callback: (project: {
|
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
trackSession: (sessionID: string, directory?: string) => void
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
trackDirectory: (directory: string) => void
|
trackDirectory: (directory: string) => void
|
||||||
}) => Promise<T>,
|
sdk: ReturnType<typeof createSdk>
|
||||||
options?: {
|
}
|
||||||
|
|
||||||
|
type ProjectOptions = {
|
||||||
extra?: string[]
|
extra?: string[]
|
||||||
model?: { providerID: string; modelID: string }
|
model?: { providerID: string; modelID: string }
|
||||||
setup?: (directory: string) => Promise<void>
|
setup?: (directory: string) => Promise<void>
|
||||||
},
|
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||||
) => Promise<T>
|
}
|
||||||
|
|
||||||
|
type TestFixtures = {
|
||||||
|
llm: LLMFixture
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
||||||
|
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkerFixtures = {
|
type WorkerFixtures = {
|
||||||
|
backend: {
|
||||||
|
url: string
|
||||||
|
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||||
|
}
|
||||||
directory: string
|
directory: string
|
||||||
slug: string
|
slug: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
|
backend: [
|
||||||
|
async ({}, use, workerInfo) => {
|
||||||
|
const handle = await startBackend(`w${workerInfo.workerIndex}`)
|
||||||
|
try {
|
||||||
|
await use({
|
||||||
|
url: handle.url,
|
||||||
|
sdk: (directory?: string) => createSdk(directory, handle.url),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await handle.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
llm: async ({}, use) => {
|
llm: async ({}, use) => {
|
||||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||||
try {
|
try {
|
||||||
|
|
@ -79,6 +116,9 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
await use({
|
await use({
|
||||||
url: svc.url,
|
url: svc.url,
|
||||||
push: (...input) => rt.runPromise(svc.push(...input)),
|
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||||
|
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||||
|
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||||
|
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
||||||
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||||
|
|
@ -146,16 +186,41 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
await use(gotoSession)
|
await use(gotoSession)
|
||||||
},
|
},
|
||||||
withProject: async ({ page }, use) => {
|
withProject: async ({ page }, use) => {
|
||||||
await use(async (callback, options) => {
|
await use((callback, options) =>
|
||||||
const root = await createTestProject()
|
runProject(page, callback, options),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
withBackendProject: async ({ page, backend }, use) => {
|
||||||
|
await use((callback, options) =>
|
||||||
|
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
async function runProject<T>(
|
||||||
|
page: Page,
|
||||||
|
callback: (project: ProjectHandle) => Promise<T>,
|
||||||
|
options?: ProjectOptions & {
|
||||||
|
serverUrl?: string
|
||||||
|
sdk?: (directory?: string) => ReturnType<typeof createSdk>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const url = options?.serverUrl
|
||||||
|
const root = await createTestProject(url ? { serverUrl: url } : undefined)
|
||||||
|
const sdk = options?.sdk?.(root) ?? createSdk(root, url)
|
||||||
const sessions = new Map<string, string>()
|
const sessions = new Map<string, string>()
|
||||||
const dirs = new Set<string>()
|
const dirs = new Set<string>()
|
||||||
await options?.setup?.(root)
|
await options?.setup?.(root)
|
||||||
await seedStorage(page, { directory: root, extra: options?.extra, model: options?.model })
|
await seedStorage(page, {
|
||||||
|
directory: root,
|
||||||
|
extra: options?.extra,
|
||||||
|
model: options?.model,
|
||||||
|
serverUrl: url,
|
||||||
|
})
|
||||||
|
|
||||||
const gotoSession = async (sessionID?: string) => {
|
const gotoSession = async (sessionID?: string) => {
|
||||||
await page.goto(sessionPath(root, sessionID))
|
await page.goto(sessionPath(root, sessionID))
|
||||||
await waitSession(page, { directory: root, sessionID })
|
await waitSession(page, { directory: root, sessionID, serverUrl: url })
|
||||||
const current = sessionIDFromUrl(page.url())
|
const current = sessionIDFromUrl(page.url())
|
||||||
if (current) trackSession(current)
|
if (current) trackSession(current)
|
||||||
}
|
}
|
||||||
|
|
@ -169,21 +234,22 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
await options?.beforeGoto?.({ directory: root, sdk })
|
||||||
await gotoSession()
|
await gotoSession()
|
||||||
const slug = await waitSlug(page)
|
const slug = await waitSlug(page)
|
||||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
|
||||||
} finally {
|
} finally {
|
||||||
setHealthPhase(page, "cleanup")
|
setHealthPhase(page, "cleanup")
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
Array.from(sessions, ([sessionID, directory]) =>
|
||||||
|
cleanupSession({ sessionID, directory, serverUrl: url }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||||
await cleanupTestProject(root)
|
await cleanupTestProject(root)
|
||||||
setHealthPhase(page, "test")
|
setHealthPhase(page, "test")
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function seedStorage(
|
async function seedStorage(
|
||||||
page: Page,
|
page: Page,
|
||||||
|
|
@ -191,6 +257,7 @@ async function seedStorage(
|
||||||
directory: string
|
directory: string
|
||||||
extra?: string[]
|
extra?: string[]
|
||||||
model?: { providerID: string; modelID: string }
|
model?: { providerID: string; modelID: string }
|
||||||
|
serverUrl?: string
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
await seedProjects(page, input)
|
await seedProjects(page, input)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
export const openaiModel = { providerID: "openai", modelID: "gpt-5.3-chat-latest" }
|
||||||
|
|
||||||
|
type Hit = { body: Record<string, unknown> }
|
||||||
|
|
||||||
|
export function bodyText(hit: Hit) {
|
||||||
|
return JSON.stringify(hit.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function titleMatch(hit: Hit) {
|
||||||
|
return bodyText(hit).includes("Generate a title for this conversation")
|
||||||
|
}
|
||||||
|
|
||||||
|
export function promptMatch(token: string) {
|
||||||
|
return (hit: Hit) => bodyText(hit).includes(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
|
||||||
|
const sdk = createSdk(undefined, input.serverUrl)
|
||||||
|
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sdk.global.config.update({
|
||||||
|
config: {
|
||||||
|
...prev,
|
||||||
|
model: `${openaiModel.providerID}/${openaiModel.modelID}`,
|
||||||
|
enabled_providers: ["openai"],
|
||||||
|
provider: {
|
||||||
|
...prev.provider,
|
||||||
|
openai: {
|
||||||
|
...prev.provider?.openai,
|
||||||
|
options: {
|
||||||
|
...prev.provider?.openai?.options,
|
||||||
|
apiKey: "test-key",
|
||||||
|
baseURL: input.llmUrl,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return await input.fn()
|
||||||
|
} finally {
|
||||||
|
await sdk.global.config.update({ config: prev })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,47 +1,53 @@
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
||||||
|
import { openaiModel, promptMatch, withMockOpenAI } from "./mock"
|
||||||
|
|
||||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
// the connection open while the agent works, causing "Failed to fetch" over
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => {
|
test("prompt succeeds when sync message endpoint is unreachable", async ({
|
||||||
|
page,
|
||||||
|
llm,
|
||||||
|
backend,
|
||||||
|
withBackendProject,
|
||||||
|
}) => {
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
await gotoSession()
|
await withMockOpenAI({
|
||||||
|
serverUrl: backend.url,
|
||||||
|
llmUrl: llm.url,
|
||||||
|
fn: async () => {
|
||||||
const token = `E2E_ASYNC_${Date.now()}`
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
|
await llm.textMatch(promptMatch(token), token)
|
||||||
|
|
||||||
|
await withBackendProject(
|
||||||
|
async (project) => {
|
||||||
await page.locator(promptSelector).click()
|
await page.locator(promptSelector).click()
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
await page.keyboard.press("Enter")
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
const sessionID = sessionIDFromUrl(page.url())!
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
|
project.trackSession(sessionID)
|
||||||
|
|
||||||
|
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
try {
|
|
||||||
// Agent response arrives via SSE despite sync endpoint being dead
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 })
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 90_000 },
|
|
||||||
)
|
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
} finally {
|
},
|
||||||
await cleanupSession({ sdk, sessionID })
|
{
|
||||||
}
|
model: openaiModel,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,9 @@
|
||||||
import fs from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
|
||||||
import { test, expect } from "../fixtures"
|
import { test, expect } from "../fixtures"
|
||||||
import { promptSelector } from "../selectors"
|
import { promptSelector } from "../selectors"
|
||||||
import { sessionIDFromUrl } from "../actions"
|
import { assistantText, sessionIDFromUrl } from "../actions"
|
||||||
import { createSdk } from "../utils"
|
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||||
|
|
||||||
async function config(dir: string, url: string) {
|
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
||||||
await fs.writeFile(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
enabled_providers: ["e2e-llm"],
|
|
||||||
provider: {
|
|
||||||
"e2e-llm": {
|
|
||||||
name: "E2E LLM",
|
|
||||||
npm: "@ai-sdk/openai-compatible",
|
|
||||||
env: [],
|
|
||||||
models: {
|
|
||||||
"test-model": {
|
|
||||||
name: "Test Model",
|
|
||||||
tool_call: true,
|
|
||||||
limit: { context: 128000, output: 32000 },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
apiKey: "test-key",
|
|
||||||
baseURL: url,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
agent: {
|
|
||||||
build: {
|
|
||||||
model: "e2e-llm/test-model",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
test("can send a prompt and receive a reply", async ({ page, llm, withProject }) => {
|
|
||||||
test.setTimeout(120_000)
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
const pageErrors: string[] = []
|
const pageErrors: string[] = []
|
||||||
|
|
@ -48,14 +13,17 @@ test("can send a prompt and receive a reply", async ({ page, llm, withProject })
|
||||||
page.on("pageerror", onPageError)
|
page.on("pageerror", onPageError)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await withProject(
|
await withMockOpenAI({
|
||||||
async (project) => {
|
serverUrl: backend.url,
|
||||||
const sdk = createSdk(project.directory)
|
llmUrl: llm.url,
|
||||||
|
fn: async () => {
|
||||||
const token = `E2E_OK_${Date.now()}`
|
const token = `E2E_OK_${Date.now()}`
|
||||||
|
|
||||||
await llm.text(token)
|
await llm.textMatch(titleMatch, "E2E Title")
|
||||||
await project.gotoSession()
|
await llm.textMatch(promptMatch(token), token)
|
||||||
|
|
||||||
|
await withBackendProject(
|
||||||
|
async (project) => {
|
||||||
const prompt = page.locator(promptSelector)
|
const prompt = page.locator(promptSelector)
|
||||||
await prompt.click()
|
await prompt.click()
|
||||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||||
|
|
@ -70,26 +38,18 @@ test("can send a prompt and receive a reply", async ({ page, llm, withProject })
|
||||||
})()
|
})()
|
||||||
project.trackSession(sessionID)
|
project.trackSession(sessionID)
|
||||||
|
|
||||||
|
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||||
|
|
||||||
await expect
|
await expect
|
||||||
.poll(
|
.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 })
|
||||||
async () => {
|
|
||||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
|
||||||
return messages
|
|
||||||
.filter((m) => m.info.role === "assistant")
|
|
||||||
.flatMap((m) => m.parts)
|
|
||||||
.filter((p) => p.type === "text")
|
|
||||||
.map((p) => p.text)
|
|
||||||
.join("\n")
|
|
||||||
},
|
|
||||||
{ timeout: 30_000 },
|
|
||||||
)
|
|
||||||
.toContain(token)
|
.toContain(token)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
model: { providerID: "e2e-llm", modelID: "test-model" },
|
model: openaiModel,
|
||||||
setup: (dir) => config(dir, llm.url),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
} finally {
|
} finally {
|
||||||
page.off("pageerror", onPageError)
|
page.off("pageerror", onPageError)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
||||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||||
export const terminalToggleKey = "Control+Backquote"
|
export const terminalToggleKey = "Control+Backquote"
|
||||||
|
|
||||||
export function createSdk(directory?: string) {
|
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
||||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveDirectory(directory: string) {
|
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
||||||
return createSdk(directory)
|
return createSdk(directory, baseUrl)
|
||||||
.path.get()
|
.path.get()
|
||||||
.then((x) => x.data?.directory ?? directory)
|
.then((x) => x.data?.directory ?? directory)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getWorktree() {
|
export async function getWorktree(baseUrl = serverUrl) {
|
||||||
const sdk = createSdk()
|
const sdk = createSdk(undefined, baseUrl)
|
||||||
const result = await sdk.path.get()
|
const result = await sdk.path.get()
|
||||||
const data = result.data
|
const data = result.data
|
||||||
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${serverUrl}/path`)
|
if (!data?.worktree) throw new Error(`Failed to resolve a worktree from ${baseUrl}/path`)
|
||||||
return data.worktree
|
return data.worktree
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,13 @@ type Hit = {
|
||||||
body: Record<string, unknown>
|
body: Record<string, unknown>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Match = (hit: Hit) => boolean
|
||||||
|
|
||||||
|
type Queue = {
|
||||||
|
item: Item
|
||||||
|
match?: Match
|
||||||
|
}
|
||||||
|
|
||||||
type Wait = {
|
type Wait = {
|
||||||
count: number
|
count: number
|
||||||
ready: Deferred.Deferred<void>
|
ready: Deferred.Deferred<void>
|
||||||
|
|
@ -420,7 +427,7 @@ const reset = Effect.fn("TestLLMServer.reset")(function* (item: Sse) {
|
||||||
for (const part of item.tail) res.write(line(part))
|
for (const part of item.tail) res.write(line(part))
|
||||||
res.destroy(new Error("connection reset"))
|
res.destroy(new Error("connection reset"))
|
||||||
})
|
})
|
||||||
yield* Effect.never
|
return yield* Effect.never
|
||||||
})
|
})
|
||||||
|
|
||||||
function fail(item: HttpError) {
|
function fail(item: HttpError) {
|
||||||
|
|
@ -581,6 +588,9 @@ namespace TestLLMServer {
|
||||||
export interface Service {
|
export interface Service {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly push: (...input: (Item | Reply)[]) => Effect.Effect<void>
|
readonly push: (...input: (Item | Reply)[]) => Effect.Effect<void>
|
||||||
|
readonly pushMatch: (match: Match, ...input: (Item | Reply)[]) => Effect.Effect<void>
|
||||||
|
readonly textMatch: (match: Match, value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
||||||
|
readonly toolMatch: (match: Match, name: string, input: unknown) => Effect.Effect<void>
|
||||||
readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
readonly text: (value: string, opts?: { usage?: Usage }) => Effect.Effect<void>
|
||||||
readonly tool: (name: string, input: unknown) => Effect.Effect<void>
|
readonly tool: (name: string, input: unknown) => Effect.Effect<void>
|
||||||
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
|
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
|
||||||
|
|
@ -605,11 +615,15 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
const router = yield* HttpRouter.HttpRouter
|
const router = yield* HttpRouter.HttpRouter
|
||||||
|
|
||||||
let hits: Hit[] = []
|
let hits: Hit[] = []
|
||||||
let list: Item[] = []
|
let list: Queue[] = []
|
||||||
let waits: Wait[] = []
|
let waits: Wait[] = []
|
||||||
|
|
||||||
const queue = (...input: (Item | Reply)[]) => {
|
const queue = (...input: (Item | Reply)[]) => {
|
||||||
list = [...list, ...input.map(item)]
|
list = [...list, ...input.map((value) => ({ item: item(value) }))]
|
||||||
|
}
|
||||||
|
|
||||||
|
const queueMatch = (match: Match, ...input: (Item | Reply)[]) => {
|
||||||
|
list = [...list, ...input.map((value) => ({ item: item(value), match }))]
|
||||||
}
|
}
|
||||||
|
|
||||||
const notify = Effect.fnUntraced(function* () {
|
const notify = Effect.fnUntraced(function* () {
|
||||||
|
|
@ -619,19 +633,21 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
|
yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
|
||||||
})
|
})
|
||||||
|
|
||||||
const pull = () => {
|
const pull = (hit: Hit) => {
|
||||||
const first = list[0]
|
const index = list.findIndex((entry) => !entry.match || entry.match(hit))
|
||||||
if (!first) return
|
if (index === -1) return
|
||||||
list = list.slice(1)
|
const first = list[index]
|
||||||
return first
|
list = [...list.slice(0, index), ...list.slice(index + 1)]
|
||||||
|
return first.item
|
||||||
}
|
}
|
||||||
|
|
||||||
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
|
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
|
||||||
const req = yield* HttpServerRequest.HttpServerRequest
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
const next = pull()
|
|
||||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
|
||||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||||
hits = [...hits, hit(req.originalUrl, body)]
|
const current = hit(req.originalUrl, body)
|
||||||
|
const next = pull(current)
|
||||||
|
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||||
|
hits = [...hits, current]
|
||||||
yield* notify()
|
yield* notify()
|
||||||
if (next.type !== "sse") return fail(next)
|
if (next.type !== "sse") return fail(next)
|
||||||
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
||||||
|
|
@ -655,6 +671,21 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||||
push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
|
push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
|
||||||
queue(...input)
|
queue(...input)
|
||||||
}),
|
}),
|
||||||
|
pushMatch: Effect.fn("TestLLMServer.pushMatch")(function* (match: Match, ...input: (Item | Reply)[]) {
|
||||||
|
queueMatch(match, ...input)
|
||||||
|
}),
|
||||||
|
textMatch: Effect.fn("TestLLMServer.textMatch")(function* (
|
||||||
|
match: Match,
|
||||||
|
value: string,
|
||||||
|
opts?: { usage?: Usage },
|
||||||
|
) {
|
||||||
|
const out = reply().text(value)
|
||||||
|
if (opts?.usage) out.usage(opts.usage)
|
||||||
|
queueMatch(match, out.stop().item())
|
||||||
|
}),
|
||||||
|
toolMatch: Effect.fn("TestLLMServer.toolMatch")(function* (match: Match, name: string, input: unknown) {
|
||||||
|
queueMatch(match, reply().tool(name, input).item())
|
||||||
|
}),
|
||||||
text: Effect.fn("TestLLMServer.text")(function* (value: string, opts?: { usage?: Usage }) {
|
text: Effect.fn("TestLLMServer.text")(function* (value: string, opts?: { usage?: Usage }) {
|
||||||
const out = reply().text(value)
|
const out = reply().text(value)
|
||||||
if (opts?.usage) out.usage(opts.usage)
|
if (opts?.usage) out.usage(opts.usage)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue