mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-06 05:54:54 +00:00
Compare commits
23 Commits
v1.3.13
...
effect/sum
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
839efafad6 | ||
|
|
c619caefdd | ||
|
|
c559af51ce | ||
|
|
d1e0a4640c | ||
|
|
f9e71ec515 | ||
|
|
ef538c9707 | ||
|
|
2f405daa98 | ||
|
|
a9c85b7c27 | ||
|
|
897d83c589 | ||
|
|
0a125e5d4d | ||
|
|
38d2276592 | ||
|
|
d58004a864 | ||
|
|
5fd833aa18 | ||
|
|
44f83015cd | ||
|
|
9a1c9ae15a | ||
|
|
a3a6cf1c07 | ||
|
|
47a676111a | ||
|
|
1df5ad470a | ||
|
|
506dd75818 | ||
|
|
c8ecd64022 | ||
|
|
ca376a4cff | ||
|
|
7532d99e5b | ||
|
|
181b5f6236 |
1
.github/VOUCHED.td
vendored
1
.github/VOUCHED.td
vendored
@@ -11,6 +11,7 @@ adamdotdevin
|
||||
-agusbasari29 AI PR slop
|
||||
ariane-emory
|
||||
-atharvau AI review spamming literally every PR
|
||||
-borealbytes
|
||||
-danieljoshuanazareth
|
||||
-danieljoshuanazareth
|
||||
edemaine
|
||||
|
||||
10
bun.lock
10
bun.lock
@@ -612,7 +612,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/platform-node": "4.0.0-beta.42",
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -636,7 +636,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.42",
|
||||
"effect": "4.0.0-beta.43",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -995,9 +995,9 @@
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.43", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.43", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43", "ioredis": "^5.7.0" } }, "sha512-Uq6E1rjaIpjHauzjwoB2HzAg3battYt2Boy8XO50GoHiWCXKE6WapYZ0/AnaBx5v5qg2sOfqpuiLsUf9ZgxOkA=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -2771,7 +2771,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="],
|
||||
"effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-g6zIq2w5nM+UPMsOzqB5EbCi4a9xkarin27WwTHqzHo=",
|
||||
"aarch64-linux": "sha256-ivRoB8jrPtMGaphkIQuZ4G5E83/iPjoMGQGgxIp6p8Q=",
|
||||
"aarch64-darwin": "sha256-0oW0PQpcXsNOGA868BjGRAAJWKcZJ8GVVkQueJfz6ng=",
|
||||
"x86_64-darwin": "sha256-7Jm198RsxBC9yewcTlV6HXLtT3+GRFThi+5vL/R8d18="
|
||||
"x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
|
||||
"aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
|
||||
"aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
|
||||
"x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.42",
|
||||
"@effect/platform-node": "4.0.0-beta.43",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
@@ -45,7 +45,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.42",
|
||||
"effect": "4.0.0-beta.43",
|
||||
"ai": "6.0.138",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -312,10 +312,11 @@ export async function openSettings(page: Page) {
|
||||
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(
|
||||
(args: { directory: string; serverUrl: string; extra: string[] }) => {
|
||||
const key = "opencode.global.dat:server"
|
||||
const defaultKey = "opencode.settings.dat:defaultServerUrl"
|
||||
const raw = localStorage.getItem(key)
|
||||
const parsed = (() => {
|
||||
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 projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||
const nextProjects = { ...(projects as Record<string, unknown>) }
|
||||
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||
|
||||
const add = (origin: string, directory: string) => {
|
||||
const current = nextProjects[origin]
|
||||
@@ -356,17 +358,18 @@ export async function seedProjects(page: Page, input: { directory: string; extra
|
||||
localStorage.setItem(
|
||||
key,
|
||||
JSON.stringify({
|
||||
list,
|
||||
list: nextList,
|
||||
projects: nextProjects,
|
||||
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 id = `e2e-${path.basename(root)}`
|
||||
|
||||
@@ -381,7 +384,7 @@ export async function createTestProject() {
|
||||
stdio: "ignore",
|
||||
})
|
||||
|
||||
return resolveDirectory(root)
|
||||
return resolveDirectory(root, input?.serverUrl)
|
||||
}
|
||||
|
||||
export async function cleanupTestProject(directory: string) {
|
||||
@@ -430,22 +433,22 @@ export async function waitSlug(page: Page, skip: string[] = []) {
|
||||
return next
|
||||
}
|
||||
|
||||
export async function resolveSlug(slug: string) {
|
||||
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
||||
const directory = base64Decode(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 }
|
||||
}
|
||||
|
||||
export async function waitDir(page: Page, directory: string) {
|
||||
const target = await resolveDirectory(directory)
|
||||
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
||||
const target = await resolveDirectory(directory, input?.serverUrl)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitDir")
|
||||
const slug = slugFromUrl(page.url())
|
||||
if (!slug) return ""
|
||||
return resolveSlug(slug)
|
||||
return resolveSlug(slug, input)
|
||||
.then((item) => item.directory)
|
||||
.catch(() => "")
|
||||
},
|
||||
@@ -455,15 +458,15 @@ export async function waitDir(page: Page, directory: string) {
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string }) {
|
||||
const target = await resolveDirectory(input.directory)
|
||||
export async function waitSession(page: Page, input: { directory: string; sessionID?: string; serverUrl?: string }) {
|
||||
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
await assertHealthy(page, "waitSession")
|
||||
const slug = slugFromUrl(page.url())
|
||||
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
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
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?.sessionID) return false
|
||||
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
|
||||
}
|
||||
|
||||
@@ -489,9 +492,9 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
return { directory: target, slug: base64Encode(target) }
|
||||
}
|
||||
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000) {
|
||||
const sdk = createSdk(directory)
|
||||
const target = await resolveDirectory(directory)
|
||||
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
||||
const sdk = createSdk(directory, serverUrl)
|
||||
const target = await resolveDirectory(directory, serverUrl)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
@@ -501,7 +504,7 @@ export async function waitSessionSaved(directory: string, sessionID: string, tim
|
||||
.then((x) => x.data)
|
||||
.catch(() => undefined)
|
||||
if (!data?.directory) return ""
|
||||
return resolveDirectory(data.directory).catch(() => data.directory)
|
||||
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
||||
},
|
||||
{ timeout },
|
||||
)
|
||||
@@ -666,8 +669,9 @@ export async function cleanupSession(input: {
|
||||
sessionID: string
|
||||
directory?: string
|
||||
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")
|
||||
await waitSessionIdle(sdk, input.sessionID, 5_000).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()
|
||||
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")
|
||||
}
|
||||
|
||||
125
packages/app/e2e/backend.ts
Normal file
125
packages/app/e2e/backend.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { ManagedRuntime } from "effect"
|
||||
import type { E2EWindow } from "../src/testing/terminal"
|
||||
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
||||
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
||||
import { startBackend } from "./backend"
|
||||
import {
|
||||
healthPhase,
|
||||
cleanupSession,
|
||||
@@ -13,6 +17,38 @@ import {
|
||||
} from "./actions"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
type LLMFixture = {
|
||||
url: string
|
||||
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>
|
||||
tool: (name: string, input: unknown) => Promise<void>
|
||||
toolHang: (name: string, input: unknown) => Promise<void>
|
||||
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
||||
fail: (message?: unknown) => Promise<void>
|
||||
error: (status: number, body: unknown) => Promise<void>
|
||||
hang: () => Promise<void>
|
||||
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
||||
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||
calls: () => Promise<number>
|
||||
wait: (count: number) => Promise<void>
|
||||
inputs: () => Promise<Record<string, unknown>[]>
|
||||
pending: () => Promise<number>
|
||||
}
|
||||
|
||||
export const settingsKey = "settings.v3"
|
||||
|
||||
const seedModel = (() => {
|
||||
@@ -25,27 +61,82 @@ const seedModel = (() => {
|
||||
}
|
||||
})()
|
||||
|
||||
type ProjectHandle = {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
}
|
||||
|
||||
type ProjectOptions = {
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
setup?: (directory: string) => Promise<void>
|
||||
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||
}
|
||||
|
||||
type TestFixtures = {
|
||||
llm: LLMFixture
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
withProject: <T>(
|
||||
callback: (project: {
|
||||
directory: string
|
||||
slug: string
|
||||
gotoSession: (sessionID?: string) => Promise<void>
|
||||
trackSession: (sessionID: string, directory?: string) => void
|
||||
trackDirectory: (directory: string) => void
|
||||
}) => Promise<T>,
|
||||
options?: { extra?: string[] },
|
||||
) => Promise<T>
|
||||
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
||||
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
|
||||
}
|
||||
|
||||
type WorkerFixtures = {
|
||||
backend: {
|
||||
url: string
|
||||
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||
}
|
||||
directory: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
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) => {
|
||||
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||
try {
|
||||
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||
await use({
|
||||
url: svc.url,
|
||||
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)),
|
||||
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||
hang: () => rt.runPromise(svc.hang),
|
||||
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||
hits: () => rt.runPromise(svc.hits),
|
||||
calls: () => rt.runPromise(svc.calls),
|
||||
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||
inputs: () => rt.runPromise(svc.inputs),
|
||||
pending: () => rt.runPromise(svc.pending),
|
||||
})
|
||||
} finally {
|
||||
await rt.dispose()
|
||||
}
|
||||
},
|
||||
page: async ({ page }, use) => {
|
||||
let boundary: string | undefined
|
||||
setHealthPhase(page, "test")
|
||||
@@ -95,45 +186,76 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(gotoSession)
|
||||
},
|
||||
withProject: async ({ page }, use) => {
|
||||
await use(async (callback, options) => {
|
||||
const root = await createTestProject()
|
||||
const sessions = new Map<string, string>()
|
||||
const dirs = new Set<string>()
|
||||
await seedStorage(page, { directory: root, extra: options?.extra })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await waitSession(page, { directory: root, sessionID })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
})
|
||||
await use((callback, options) => 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 seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||
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 dirs = new Set<string>()
|
||||
await options?.setup?.(root)
|
||||
await seedStorage(page, {
|
||||
directory: root,
|
||||
extra: options?.extra,
|
||||
model: options?.model,
|
||||
serverUrl: url,
|
||||
})
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(root, sessionID))
|
||||
await waitSession(page, { directory: root, sessionID, serverUrl: url })
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (current) trackSession(current)
|
||||
}
|
||||
|
||||
const trackSession = (sessionID: string, directory?: string) => {
|
||||
sessions.set(sessionID, directory ?? root)
|
||||
}
|
||||
|
||||
const trackDirectory = (directory: string) => {
|
||||
if (directory !== root) dirs.add(directory)
|
||||
}
|
||||
|
||||
try {
|
||||
await options?.beforeGoto?.({ directory: root, sdk })
|
||||
await gotoSession()
|
||||
const slug = await waitSlug(page)
|
||||
return await callback({ directory: root, slug, gotoSession, trackSession, trackDirectory, sdk })
|
||||
} finally {
|
||||
setHealthPhase(page, "cleanup")
|
||||
await Promise.allSettled(
|
||||
Array.from(sessions, ([sessionID, directory]) => cleanupSession({ sessionID, directory, serverUrl: url })),
|
||||
)
|
||||
await Promise.allSettled(Array.from(dirs, (directory) => cleanupTestProject(directory)))
|
||||
await cleanupTestProject(root)
|
||||
setHealthPhase(page, "test")
|
||||
}
|
||||
}
|
||||
|
||||
async function seedStorage(
|
||||
page: Page,
|
||||
input: {
|
||||
directory: string
|
||||
extra?: string[]
|
||||
model?: { providerID: string; modelID: string }
|
||||
serverUrl?: string
|
||||
},
|
||||
) {
|
||||
await seedProjects(page, input)
|
||||
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
||||
const win = window as E2EWindow
|
||||
@@ -158,7 +280,7 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
}, seedModel)
|
||||
}, input.model ?? seedModel)
|
||||
}
|
||||
|
||||
export { expect }
|
||||
|
||||
46
packages/app/e2e/prompt/mock.ts
Normal file
46
packages/app/e2e/prompt/mock.ts
Normal file
@@ -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,52 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { assistantText, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||
// the connection open while the agent works, causing "Failed to fetch" over
|
||||
// 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)
|
||||
|
||||
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||
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()}`
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(token), token)
|
||||
|
||||
const token = `E2E_ASYNC_${Date.now()}`
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
await page.locator(promptSelector).click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
project.trackSession(sessionID)
|
||||
|
||||
try {
|
||||
// Agent response arrives via SSE despite sync endpoint being dead
|
||||
await expect
|
||||
.poll(
|
||||
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")
|
||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.toContain(token)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { withSession } from "../actions"
|
||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||
|
||||
@@ -43,20 +44,13 @@ async function wait(page: Page, value: string) {
|
||||
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||
}
|
||||
|
||||
async function reply(sdk: Parameters<typeof withSession>[0], sessionID: string, token: string) {
|
||||
async function reply(
|
||||
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
|
||||
sessionID: string,
|
||||
token: string,
|
||||
) {
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||
return messages
|
||||
.filter((item) => item.info.role === "assistant")
|
||||
.flatMap((item) => item.parts)
|
||||
.filter((item) => item.type === "text")
|
||||
.map((item) => item.text)
|
||||
.join("\n")
|
||||
},
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
@@ -79,106 +73,145 @@ async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string,
|
||||
.toContain(token)
|
||||
}
|
||||
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({ page, sdk, gotoSession }) => {
|
||||
test("prompt history restores unsent draft with arrow navigation", async ({
|
||||
page,
|
||||
llm,
|
||||
backend,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e prompt history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||
const first = `Reply with exactly: ${firstToken}`
|
||||
const second = `Reply with exactly: ${secondToken}`
|
||||
const draft = `draft ${Date.now()}`
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(firstToken), firstToken)
|
||||
await llm.textMatch(promptMatch(secondToken), secondToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, firstToken)
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, secondToken)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
project.trackSession(sessionID)
|
||||
await reply(project.sdk, sessionID, firstToken)
|
||||
|
||||
// Clear the draft before navigating history (ArrowUp only works when prompt is empty)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
await prompt.click()
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, secondToken)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(draft)
|
||||
await wait(page, draft)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
await prompt.fill("")
|
||||
await wait(page, "")
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||
test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withSession(sdk, `e2e shell history ${Date.now()}`, async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||
const first = `echo ${firstToken}`
|
||||
const second = `echo ${secondToken}`
|
||||
const normal = `Reply with exactly: ${normalToken}`
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(normalToken), normalToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, first, firstToken)
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(sdk, session.id, second, secondToken)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(first)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
const sessionID = sessionIDFromUrl(page.url())!
|
||||
project.trackSession(sessionID)
|
||||
await shell(project.sdk, sessionID, first, firstToken)
|
||||
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.type(second)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await shell(project.sdk, sessionID, second, secondToken)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
await prompt.click()
|
||||
await page.keyboard.type("!")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, second)
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, first)
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, second)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(sdk, session.id, normalToken)
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
await page.keyboard.press("Escape")
|
||||
await wait(page, "")
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type(normal)
|
||||
await page.keyboard.press("Enter")
|
||||
await wait(page, "")
|
||||
await reply(project.sdk, sessionID, normalToken)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.press("ArrowUp")
|
||||
await wait(page, normal)
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||
import { test, expect } from "../fixtures"
|
||||
import { sessionIDFromUrl } from "../actions"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { createSdk } from "../utils"
|
||||
|
||||
const isBash = (part: unknown): part is ToolPart => {
|
||||
if (!part || typeof part !== "object") return false
|
||||
@@ -11,13 +10,12 @@ const isBash = (part: unknown): part is ToolPart => {
|
||||
return "state" in part
|
||||
}
|
||||
|
||||
test("shell mode runs a command in the project directory", async ({ page, withProject }) => {
|
||||
test("shell mode runs a command in the project directory", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
const sdk = createSdk(directory)
|
||||
await withBackendProject(async ({ directory, gotoSession, trackSession, sdk }) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
const cmd = process.platform === "win32" ? "dir" : "ls"
|
||||
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||
|
||||
await gotoSession()
|
||||
await prompt.click()
|
||||
|
||||
@@ -22,43 +22,45 @@ async function seed(sdk: Parameters<typeof withSession>[0], sessionID: string) {
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("/share and /unshare update session share state", async ({ page, sdk, gotoSession }) => {
|
||||
test("/share and /unshare update session share state", async ({ page, withBackendProject }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
await withSession(sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
|
||||
await seed(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await seed(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/share")
|
||||
await expect(page.locator('[data-slash-id="session.share"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/unshare")
|
||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await prompt.click()
|
||||
await page.keyboard.type("/unshare")
|
||||
await expect(page.locator('[data-slash-id="session.unshare"]').first()).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { cleanupSession, sessionIDFromUrl, withSession } from "../actions"
|
||||
import { assistantText, sessionIDFromUrl } from "../actions"
|
||||
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
|
||||
|
||||
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
|
||||
test("can send a prompt and receive a reply", async ({ page, llm, backend, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const pageErrors: string[] = []
|
||||
@@ -11,42 +12,44 @@ test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession })
|
||||
}
|
||||
page.on("pageerror", onPageError)
|
||||
|
||||
await gotoSession()
|
||||
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const sessionID = (() => {
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return id
|
||||
})()
|
||||
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
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 },
|
||||
)
|
||||
await withMockOpenAI({
|
||||
serverUrl: backend.url,
|
||||
llmUrl: llm.url,
|
||||
fn: async () => {
|
||||
const token = `E2E_OK_${Date.now()}`
|
||||
|
||||
.toContain(token)
|
||||
await llm.textMatch(titleMatch, "E2E Title")
|
||||
await llm.textMatch(promptMatch(token), token)
|
||||
|
||||
await withBackendProject(
|
||||
async (project) => {
|
||||
const prompt = page.locator(promptSelector)
|
||||
await prompt.click()
|
||||
await page.keyboard.type(`Reply with exactly: ${token}`)
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||
|
||||
const sessionID = (() => {
|
||||
const id = sessionIDFromUrl(page.url())
|
||||
if (!id) throw new Error(`Failed to parse session id from url: ${page.url()}`)
|
||||
return id
|
||||
})()
|
||||
project.trackSession(sessionID)
|
||||
|
||||
await expect.poll(() => llm.calls()).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 30_000 }).toContain(token)
|
||||
},
|
||||
{
|
||||
model: openaiModel,
|
||||
},
|
||||
)
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
page.off("pageerror", onPageError)
|
||||
await cleanupSession({ sdk, sessionID })
|
||||
}
|
||||
|
||||
if (pageErrors.length > 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { seedSessionTask, withSession } from "../actions"
|
||||
import { test, expect } from "../fixtures"
|
||||
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, sdk, gotoSession }) => {
|
||||
test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const errs: string[] = []
|
||||
@@ -10,28 +10,32 @@ test("task tool child-session link does not trigger stale show errors", async ({
|
||||
}
|
||||
page.on("pageerror", onError)
|
||||
|
||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
const child = await seedSessionTask(sdk, {
|
||||
sessionID: session.id,
|
||||
description: "Open child session",
|
||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
|
||||
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
|
||||
trackSession(session.id)
|
||||
const child = await seedSessionTask(sdk, {
|
||||
sessionID: session.id,
|
||||
description: "Open child session",
|
||||
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
|
||||
})
|
||||
trackSession(child.sessionID)
|
||||
|
||||
try {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
.filter({ hasText: /open child session/i })
|
||||
.first()
|
||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(errs).toEqual([])
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const link = page
|
||||
.locator("a.subagent-link")
|
||||
.filter({ hasText: /open child session/i })
|
||||
.first()
|
||||
await expect(link).toBeVisible({ timeout: 30_000 })
|
||||
await link.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
|
||||
await page.waitForTimeout(1000)
|
||||
expect(errs).toEqual([])
|
||||
} finally {
|
||||
page.off("pageerror", onError)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
sessionComposerDockSelector,
|
||||
sessionTodoToggleButtonSelector,
|
||||
} from "../selectors"
|
||||
import { modKey } from "../utils"
|
||||
|
||||
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
|
||||
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
|
||||
@@ -255,168 +256,50 @@ async function withMockPermission<T>(
|
||||
}
|
||||
}
|
||||
|
||||
test("default dock shows prompt input", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock default", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock default", async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
await expect(page.locator(promptSelector)).toBeVisible()
|
||||
await expect(page.locator(questionDockSelector)).toHaveCount(0)
|
||||
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
await expect(page.locator(promptSelector)).toBeFocused()
|
||||
})
|
||||
})
|
||||
|
||||
test("auto-accept toggle works before first submit", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
await setAutoAccept(page, true)
|
||||
await setAutoAccept(page, false)
|
||||
})
|
||||
|
||||
test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock question", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
await page.locator(promptSelector).click()
|
||||
await expect(page.locator(promptSelector)).toBeFocused()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission once", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
test("auto-accept toggle works before first submit", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async ({ gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
const button = page.locator('[data-action="prompt-permissions"]').first()
|
||||
await expect(button).toBeVisible()
|
||||
await expect(button).toHaveAttribute("aria-pressed", "false")
|
||||
|
||||
await setAutoAccept(page, true)
|
||||
await setAutoAccept(page, false)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports reject", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow always", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock permission always", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child question parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
try {
|
||||
await withDockSeed(sdk, child.id, async () => {
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: child.id,
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue child" },
|
||||
{ label: "Stop", description: "Stop child" },
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -430,40 +313,96 @@ test("child session question request blocks parent dock and unblocks after submi
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||
page,
|
||||
sdk,
|
||||
gotoSession,
|
||||
}) => {
|
||||
await withDockSession(sdk, "e2e composer dock child permission parent", async (session) => {
|
||||
await gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const child = await sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
const second = dock.locator('[data-slot="question-option"]').nth(1)
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await expect(second).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Space")
|
||||
await page.keyboard.press(`${modKey}+Enter`)
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock question escape", async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue now" },
|
||||
{ label: "Stop", description: "Stop here" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
const first = dock.locator('[data-slot="question-option"]').first()
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
await expect(first).toBeFocused()
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
id: "per_e2e_once",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
patterns: ["/tmp/opencode-e2e-perm-once"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
{ child },
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
@@ -471,67 +410,218 @@ test("child session permission request blocks parent dock and supports allow onc
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await cleanupSession({ sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock todo", async (session) => {
|
||||
const dock = await todoDock(page, session.id)
|
||||
await gotoSession(session.id)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
|
||||
try {
|
||||
await dock.open([
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
])
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.collapse()
|
||||
await dock.expectCollapsed(["pending", "in_progress"])
|
||||
|
||||
await dock.expand()
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.finish([
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
])
|
||||
await dock.expectClosed()
|
||||
} finally {
|
||||
await dock.clear()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSession }) => {
|
||||
await withDockSession(sdk, "e2e composer dock keyboard", async (session) => {
|
||||
await withDockSeed(sdk, session.id, async () => {
|
||||
await gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [{ label: "Continue", description: "Continue now" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_reject",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-reject"],
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /deny/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_always",
|
||||
sessionID: session.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-always"],
|
||||
metadata: { description: "Need permission for command" },
|
||||
},
|
||||
undefined,
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow always/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("child session question request blocks parent dock and unblocks after submit", async ({
|
||||
page,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const child = await project.sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child question",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
try {
|
||||
await withDockSeed(project.sdk, child.id, async () => {
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: child.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Child input",
|
||||
question: "Pick one child option",
|
||||
options: [
|
||||
{ label: "Continue", description: "Continue child" },
|
||||
{ label: "Stop", description: "Stop child" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const dock = page.locator(questionDockSelector)
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await dock.locator('[data-slot="question-option"]').first().click()
|
||||
await dock.getByRole("button", { name: /submit/i }).click()
|
||||
|
||||
await expectQuestionOpen(page)
|
||||
})
|
||||
} finally {
|
||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("child session permission request blocks parent dock and supports allow once", async ({
|
||||
page,
|
||||
withBackendProject,
|
||||
}) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
await setAutoAccept(page, false)
|
||||
|
||||
const child = await project.sdk.session
|
||||
.create({
|
||||
title: "e2e composer dock child permission",
|
||||
parentID: session.id,
|
||||
})
|
||||
.then((r) => r.data)
|
||||
if (!child?.id) throw new Error("Child session create did not return an id")
|
||||
|
||||
try {
|
||||
await withMockPermission(
|
||||
page,
|
||||
{
|
||||
id: "per_e2e_child",
|
||||
sessionID: child.id,
|
||||
permission: "bash",
|
||||
patterns: ["/tmp/opencode-e2e-perm-child"],
|
||||
metadata: { description: "Need child permission" },
|
||||
},
|
||||
{ child },
|
||||
async (state) => {
|
||||
await page.goto(page.url())
|
||||
await expectPermissionBlocked(page)
|
||||
|
||||
await clearPermissionDock(page, /allow once/i)
|
||||
await state.resolved()
|
||||
await page.goto(page.url())
|
||||
|
||||
await expectPermissionOpen(page)
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => {
|
||||
const dock = await todoDock(page, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
|
||||
|
||||
try {
|
||||
await dock.open([
|
||||
{ content: "first task", status: "pending", priority: "high" },
|
||||
{ content: "second task", status: "in_progress", priority: "medium" },
|
||||
])
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.collapse()
|
||||
await dock.expectCollapsed(["pending", "in_progress"])
|
||||
|
||||
await dock.expand()
|
||||
await dock.expectOpen(["pending", "in_progress"])
|
||||
|
||||
await dock.finish([
|
||||
{ content: "first task", status: "completed", priority: "high" },
|
||||
{ content: "second task", status: "cancelled", priority: "medium" },
|
||||
])
|
||||
await dock.expectClosed()
|
||||
} finally {
|
||||
await dock.clear()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
|
||||
await withBackendProject(async (project) => {
|
||||
await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
|
||||
await withDockSeed(project.sdk, session.id, async () => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
await seedSessionQuestion(project.sdk, {
|
||||
sessionID: session.id,
|
||||
questions: [
|
||||
{
|
||||
header: "Need input",
|
||||
question: "Pick one option",
|
||||
options: [{ label: "Continue", description: "Continue now" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await expectQuestionBlocked(page)
|
||||
|
||||
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.type("abc")
|
||||
await expect(page.locator(promptSelector)).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,13 +49,13 @@ async function seedConversation(input: {
|
||||
return { prompt, userMessageID }
|
||||
}
|
||||
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
|
||||
test("slash undo sets revert and restores prior prompt", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `undo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await withBackendProject(async (project) => {
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
@@ -81,13 +81,13 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withProj
|
||||
})
|
||||
})
|
||||
|
||||
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
|
||||
test("slash redo clears revert and restores latest state", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const token = `redo_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await withBackendProject(async (project) => {
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
@@ -128,14 +128,14 @@ test("slash redo clears revert and restores latest state", async ({ page, withPr
|
||||
})
|
||||
})
|
||||
|
||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
|
||||
test("slash undo/redo traverses multi-step revert stack", async ({ page, withBackendProject }) => {
|
||||
test.setTimeout(120_000)
|
||||
|
||||
const firstToken = `undo_redo_first_${Date.now()}`
|
||||
const secondToken = `undo_redo_second_${Date.now()}`
|
||||
|
||||
await withProject(async (project) => {
|
||||
const sdk = createSdk(project.directory)
|
||||
await withBackendProject(async (project) => {
|
||||
const sdk = project.sdk
|
||||
|
||||
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
@@ -31,144 +31,152 @@ async function seedMessage(sdk: Sdk, sessionID: string) {
|
||||
.toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be renamed via header menu", async ({ page, withBackendProject }) => {
|
||||
const stamp = Date.now()
|
||||
const originalTitle = `e2e rename test ${stamp}`
|
||||
const renamedTitle = `e2e renamed ${stamp}`
|
||||
|
||||
await withSession(sdk, originalTitle, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, originalTitle, async (session) => {
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
|
||||
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /rename/i)
|
||||
|
||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
await expect(input).toHaveValue(renamedTitle)
|
||||
await input.press("Enter")
|
||||
const input = page.locator(".scroll-view__viewport").locator(inlineInputSelector).first()
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toBeFocused()
|
||||
await input.fill(renamedTitle)
|
||||
await expect(input).toHaveValue(renamedTitle)
|
||||
await input.press("Enter")
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.title
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(renamedTitle)
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.title
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(renamedTitle)
|
||||
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(renamedTitle)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be archived via header menu", async ({ page, withBackendProject }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e archive test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /archive/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.time?.archived
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.time?.archived
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be deleted via header menu", async ({ page, withBackendProject }) => {
|
||||
const stamp = Date.now()
|
||||
const title = `e2e delete test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
const menu = await openSessionMoreMenu(page, session.id)
|
||||
await clickMenuItem(menu, /delete/i)
|
||||
await confirmDialog(page, /delete/i)
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session
|
||||
.get({ sessionID: session.id })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
return data?.id
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session
|
||||
.get({ sessionID: session.id })
|
||||
.then((r) => r.data)
|
||||
.catch(() => undefined)
|
||||
return data?.id
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
await openSidebar(page)
|
||||
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
|
||||
test("session can be shared and unshared via header button", async ({ page, withBackendProject }) => {
|
||||
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
|
||||
|
||||
const stamp = Date.now()
|
||||
const title = `e2e share test ${stamp}`
|
||||
|
||||
await withSession(sdk, title, async (session) => {
|
||||
await seedMessage(sdk, session.id)
|
||||
await gotoSession(session.id)
|
||||
await withBackendProject(async (project) => {
|
||||
await withSession(project.sdk, title, async (session) => {
|
||||
await seedMessage(project.sdk, session.id)
|
||||
await project.gotoSession(session.id)
|
||||
|
||||
const shared = await openSharePopover(page)
|
||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||
await publish.click()
|
||||
const shared = await openSharePopover(page)
|
||||
const publish = shared.popoverBody.getByRole("button", { name: "Publish" }).first()
|
||||
await expect(publish).toBeVisible({ timeout: 30_000 })
|
||||
await publish.click()
|
||||
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.not.toBeUndefined()
|
||||
|
||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
const unpublish = shared.popoverBody.getByRole("button", { name: "Unpublish" }).first()
|
||||
await expect(unpublish).toBeVisible({ timeout: 30_000 })
|
||||
await unpublish.click()
|
||||
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
await expect(shared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const data = await project.sdk.session.get({ sessionID: session.id }).then((r) => r.data)
|
||||
return data?.share?.url || undefined
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBeUndefined()
|
||||
|
||||
const unshared = await openSharePopover(page)
|
||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
const unshared = await openSharePopover(page)
|
||||
await expect(unshared.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,21 +26,21 @@ export const serverNamePattern = new RegExp(`(?:${serverNames.map(escape).join("
|
||||
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
|
||||
export const terminalToggleKey = "Control+Backquote"
|
||||
|
||||
export function createSdk(directory?: string) {
|
||||
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
|
||||
export function createSdk(directory?: string, baseUrl = serverUrl) {
|
||||
return createOpencodeClient({ baseUrl, directory, throwOnError: true })
|
||||
}
|
||||
|
||||
export async function resolveDirectory(directory: string) {
|
||||
return createSdk(directory)
|
||||
export async function resolveDirectory(directory: string, baseUrl = serverUrl) {
|
||||
return createSdk(directory, baseUrl)
|
||||
.path.get()
|
||||
.then((x) => x.data?.directory ?? directory)
|
||||
}
|
||||
|
||||
export async function getWorktree() {
|
||||
const sdk = createSdk()
|
||||
export async function getWorktree(baseUrl = serverUrl) {
|
||||
const sdk = createSdk(undefined, baseUrl)
|
||||
const result = await sdk.path.get()
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -1344,6 +1344,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
|
||||
autocorrect={store.mode === "normal" ? "on" : "off"}
|
||||
spellcheck={store.mode === "normal"}
|
||||
inputMode="text"
|
||||
// @ts-expect-error
|
||||
autocomplete="off"
|
||||
onInput={handleInput}
|
||||
onPaste={handlePaste}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
|
||||
@@ -100,6 +100,30 @@ describe("buildRequestParts", () => {
|
||||
expect(synthetic).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("adds file parts for @mentions inside comment text", () => {
|
||||
const result = buildRequestParts({
|
||||
prompt: [{ type: "text", content: "look", start: 0, end: 4 }],
|
||||
context: [
|
||||
{
|
||||
key: "ctx:comment-mention",
|
||||
type: "file",
|
||||
path: "src/review.ts",
|
||||
comment: "Compare with @src/shared.ts and @src/review.ts.",
|
||||
},
|
||||
],
|
||||
images: [],
|
||||
text: "look",
|
||||
messageID: "msg_comment_mentions",
|
||||
sessionID: "ses_comment_mentions",
|
||||
sessionDirectory: "/repo",
|
||||
})
|
||||
|
||||
const files = result.requestParts.filter((part) => part.type === "file")
|
||||
expect(files).toHaveLength(2)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/review.ts")).toBe(true)
|
||||
expect(files.some((part) => part.type === "file" && part.url === "file:///repo/src/shared.ts")).toBe(true)
|
||||
})
|
||||
|
||||
test("handles Windows paths correctly (simulated on macOS)", () => {
|
||||
const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }]
|
||||
|
||||
|
||||
@@ -39,6 +39,16 @@ const absolute = (directory: string, path: string) => {
|
||||
const fileQuery = (selection: FileSelection | undefined) =>
|
||||
selection ? `?start=${selection.startLine}&end=${selection.endLine}` : ""
|
||||
|
||||
const mention = /(^|[\s([{"'])@(\S+)/g
|
||||
|
||||
const parseCommentMentions = (comment: string) => {
|
||||
return Array.from(comment.matchAll(mention)).flatMap((match) => {
|
||||
const path = (match[2] ?? "").replace(/[.,!?;:)}\]"']+$/, "")
|
||||
if (!path) return []
|
||||
return [path]
|
||||
})
|
||||
}
|
||||
|
||||
const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
|
||||
const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
|
||||
|
||||
@@ -138,6 +148,21 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
|
||||
if (!comment) return [filePart]
|
||||
|
||||
const mentions = parseCommentMentions(comment).flatMap((path) => {
|
||||
const url = `file://${encodeFilePath(absolute(input.sessionDirectory, path))}`
|
||||
if (used.has(url)) return []
|
||||
used.add(url)
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
url,
|
||||
filename: getFilename(path),
|
||||
} satisfies PromptRequestPart,
|
||||
]
|
||||
})
|
||||
|
||||
return [
|
||||
{
|
||||
id: Identifier.ascending("part"),
|
||||
@@ -153,6 +178,7 @@ export function buildRequestParts(input: BuildRequestPartsInput) {
|
||||
}),
|
||||
} satisfies PromptRequestPart,
|
||||
filePart,
|
||||
...mentions,
|
||||
]
|
||||
})
|
||||
|
||||
|
||||
@@ -1046,6 +1046,9 @@ export default function Page() {
|
||||
onLineCommentUpdate={updateCommentInContext}
|
||||
onLineCommentDelete={removeCommentFromContext}
|
||||
lineCommentActions={reviewCommentActions()}
|
||||
commentMentions={{
|
||||
items: file.searchFilesAndDirectories,
|
||||
}}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
onFocusedCommentChange={comments.setFocus}
|
||||
|
||||
@@ -29,16 +29,20 @@ function Option(props: {
|
||||
label: string
|
||||
description?: string
|
||||
disabled: boolean
|
||||
ref?: (el: HTMLButtonElement) => void
|
||||
onFocus?: VoidFunction
|
||||
onClick: VoidFunction
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
ref={props.ref}
|
||||
data-slot="question-option"
|
||||
data-picked={props.picked}
|
||||
role={props.multi ? "checkbox" : "radio"}
|
||||
aria-checked={props.picked}
|
||||
disabled={props.disabled}
|
||||
onFocus={props.onFocus}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
<Mark multi={props.multi} picked={props.picked} />
|
||||
@@ -66,16 +70,21 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
custom: cached?.custom ?? ([] as string[]),
|
||||
customOn: cached?.customOn ?? ([] as boolean[]),
|
||||
editing: false,
|
||||
focus: 0,
|
||||
})
|
||||
|
||||
let root: HTMLDivElement | undefined
|
||||
let customRef: HTMLButtonElement | undefined
|
||||
let optsRef: HTMLButtonElement[] = []
|
||||
let replied = false
|
||||
let focusFrame: number | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const count = createMemo(() => options().length + 1)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
@@ -129,6 +138,29 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
root.style.setProperty("--question-prompt-max-height", `${max}px`)
|
||||
}
|
||||
|
||||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||
|
||||
const pickFocus = (tab: number = store.tab) => {
|
||||
const list = questions()[tab]?.options ?? []
|
||||
if (store.customOn[tab] === true) return list.length
|
||||
return Math.max(
|
||||
0,
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
const focus = (i: number) => {
|
||||
const next = clamp(i)
|
||||
setStore("focus", next)
|
||||
if (store.editing) return
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
focusFrame = requestAnimationFrame(() => {
|
||||
focusFrame = undefined
|
||||
const el = next === options().length ? customRef : optsRef[next]
|
||||
el?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let raf: number | undefined
|
||||
const update = () => {
|
||||
@@ -153,9 +185,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
observer.disconnect()
|
||||
if (raf !== undefined) cancelAnimationFrame(raf)
|
||||
})
|
||||
|
||||
focus(pickFocus())
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
|
||||
if (replied) return
|
||||
cache.set(props.request.id, {
|
||||
tab: store.tab,
|
||||
@@ -231,6 +266,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
|
||||
const customToggle = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
|
||||
if (!multi()) {
|
||||
setStore("customOn", store.tab, true)
|
||||
@@ -250,15 +286,68 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const value = input().trim()
|
||||
if (value) setStore("answers", store.tab, (current = []) => current.filter((item) => item.trim() !== value))
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const customOpen = () => {
|
||||
if (sending()) return
|
||||
setStore("focus", options().length)
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
customUpdate(input(), true)
|
||||
}
|
||||
|
||||
const move = (step: number) => {
|
||||
if (store.editing || sending()) return
|
||||
focus(store.focus + step)
|
||||
}
|
||||
|
||||
const nav = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented) return
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
void reject()
|
||||
return
|
||||
}
|
||||
|
||||
const mod = (event.metaKey || event.ctrlKey) && !event.altKey
|
||||
if (mod && event.key === "Enter") {
|
||||
if (event.repeat) return
|
||||
event.preventDefault()
|
||||
next()
|
||||
return
|
||||
}
|
||||
|
||||
const target =
|
||||
event.target instanceof HTMLElement ? event.target.closest('[data-slot="question-options"]') : undefined
|
||||
if (store.editing) return
|
||||
if (!(target instanceof HTMLElement)) return
|
||||
if (event.altKey || event.ctrlKey || event.metaKey) return
|
||||
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
move(1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
move(-1)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === "Home") {
|
||||
event.preventDefault()
|
||||
focus(0)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key !== "End") return
|
||||
event.preventDefault()
|
||||
focus(count() - 1)
|
||||
}
|
||||
|
||||
const selectOption = (optIndex: number) => {
|
||||
if (sending()) return
|
||||
|
||||
@@ -270,6 +359,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const opt = options()[optIndex]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
setStore("editing", false)
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
@@ -279,6 +369,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
const commitCustom = () => {
|
||||
setStore("editing", false)
|
||||
customUpdate(input())
|
||||
focus(options().length)
|
||||
}
|
||||
|
||||
const resizeInput = (el: HTMLTextAreaElement) => {
|
||||
@@ -308,27 +399,33 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
return
|
||||
}
|
||||
|
||||
setStore("tab", store.tab + 1)
|
||||
const tab = store.tab + 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const back = () => {
|
||||
if (sending()) return
|
||||
if (store.tab <= 0) return
|
||||
setStore("tab", store.tab - 1)
|
||||
const tab = store.tab - 1
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
const jump = (tab: number) => {
|
||||
if (sending()) return
|
||||
setStore("tab", tab)
|
||||
setStore("editing", false)
|
||||
focus(pickFocus(tab))
|
||||
}
|
||||
|
||||
return (
|
||||
<DockPrompt
|
||||
kind="question"
|
||||
ref={(el) => (root = el)}
|
||||
onKeyDown={nav}
|
||||
header={
|
||||
<>
|
||||
<div data-slot="question-header-title">{summary()}</div>
|
||||
@@ -351,7 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject}>
|
||||
<Button variant="ghost" size="large" disabled={sending()} onClick={reject} aria-keyshortcuts="Escape">
|
||||
{language.t("ui.common.dismiss")}
|
||||
</Button>
|
||||
<div data-slot="question-footer-actions">
|
||||
@@ -360,7 +457,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
{language.t("ui.common.back")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant={last() ? "primary" : "secondary"} size="large" disabled={sending()} onClick={next}>
|
||||
<Button
|
||||
variant={last() ? "primary" : "secondary"}
|
||||
size="large"
|
||||
disabled={sending()}
|
||||
onClick={next}
|
||||
aria-keyshortcuts="Meta+Enter Control+Enter"
|
||||
>
|
||||
{last() ? language.t("ui.common.submit") : language.t("ui.common.next")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -380,6 +483,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
ref={(el) => (optsRef[i()] = el)}
|
||||
onFocus={() => setStore("focus", i())}
|
||||
onClick={() => selectOption(i())}
|
||||
/>
|
||||
)}
|
||||
@@ -390,12 +495,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
@@ -440,8 +547,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
|
||||
@@ -302,6 +302,9 @@ export function FileTabContent(props: { tab: string }) {
|
||||
comments: fileComments,
|
||||
label: language.t("ui.lineComment.submit"),
|
||||
draftKey: () => path() ?? props.tab,
|
||||
mention: {
|
||||
items: file.searchFilesAndDirectories,
|
||||
},
|
||||
state: {
|
||||
opened: () => note.openedComment,
|
||||
setOpened: (id) => setNote("openedComment", id),
|
||||
|
||||
@@ -30,6 +30,9 @@ export interface SessionReviewTabProps {
|
||||
onFocusedCommentChange?: (focus: { file: string; id: string } | null) => void
|
||||
focusedFile?: string
|
||||
onScrollRef?: (el: HTMLDivElement) => void
|
||||
commentMentions?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
classes?: {
|
||||
root?: string
|
||||
header?: string
|
||||
@@ -162,6 +165,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
|
||||
onLineCommentUpdate={props.onLineCommentUpdate}
|
||||
onLineCommentDelete={props.onLineCommentDelete}
|
||||
lineCommentActions={props.lineCommentActions}
|
||||
lineCommentMention={props.commentMentions}
|
||||
comments={props.comments}
|
||||
focusedComment={props.focusedComment}
|
||||
onFocusedCommentChange={props.onFocusedCommentChange}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { app } from "electron"
|
||||
import treeKill from "tree-kill"
|
||||
|
||||
import { WSL_ENABLED_KEY } from "./constants"
|
||||
import { getUserShell, loadShellEnv, mergeShellEnv } from "./shell-env"
|
||||
import { store } from "./store"
|
||||
|
||||
const CLI_INSTALL_DIR = ".opencode/bin"
|
||||
@@ -135,7 +136,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
const base = Object.fromEntries(
|
||||
Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
const envs = {
|
||||
const env = {
|
||||
...base,
|
||||
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true",
|
||||
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
|
||||
@@ -143,8 +144,10 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
|
||||
XDG_STATE_HOME: app.getPath("userData"),
|
||||
...extraEnv,
|
||||
}
|
||||
const shell = process.platform === "win32" ? null : getUserShell()
|
||||
const envs = shell ? mergeShellEnv(loadShellEnv(shell), env) : env
|
||||
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs)
|
||||
const { cmd, cmdArgs } = buildCommand(args, envs, shell)
|
||||
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
|
||||
const child = spawn(cmd, cmdArgs, {
|
||||
env: envs,
|
||||
@@ -210,7 +213,7 @@ function handleSqliteProgress(events: EventEmitter, line: string) {
|
||||
return false
|
||||
}
|
||||
|
||||
function buildCommand(args: string, env: Record<string, string>) {
|
||||
function buildCommand(args: string, env: Record<string, string>, shell: string | null) {
|
||||
if (process.platform === "win32" && isWslEnabled()) {
|
||||
console.log(`[cli] Using WSL mode`)
|
||||
const version = app.getVersion()
|
||||
@@ -233,10 +236,10 @@ function buildCommand(args: string, env: Record<string, string>) {
|
||||
}
|
||||
|
||||
const sidecar = getSidecarPath()
|
||||
const shell = process.env.SHELL || "/bin/sh"
|
||||
const line = shell.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${shell}, command: ${line}`)
|
||||
return { cmd: shell, cmdArgs: ["-l", "-c", line] }
|
||||
const user = shell || getUserShell()
|
||||
const line = user.endsWith("/nu") ? `^\"${sidecar}\" ${args}` : `\"${sidecar}\" ${args}`
|
||||
console.log(`[cli] Unix mode, shell: ${user}, command: ${line}`)
|
||||
return { cmd: user, cmdArgs: ["-l", "-c", line] }
|
||||
}
|
||||
|
||||
function envPrefix(env: Record<string, string>) {
|
||||
|
||||
43
packages/desktop-electron/src/main/shell-env.test.ts
Normal file
43
packages/desktop-electron/src/main/shell-env.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import { isNushell, mergeShellEnv, parseShellEnv } from "./shell-env"
|
||||
|
||||
describe("shell env", () => {
|
||||
test("parseShellEnv supports null-delimited pairs", () => {
|
||||
const env = parseShellEnv(Buffer.from("PATH=/usr/bin:/bin\0FOO=bar=baz\0\0"))
|
||||
|
||||
expect(env.PATH).toBe("/usr/bin:/bin")
|
||||
expect(env.FOO).toBe("bar=baz")
|
||||
})
|
||||
|
||||
test("parseShellEnv ignores invalid entries", () => {
|
||||
const env = parseShellEnv(Buffer.from("INVALID\0=empty\0OK=1\0"))
|
||||
|
||||
expect(Object.keys(env).length).toBe(1)
|
||||
expect(env.OK).toBe("1")
|
||||
})
|
||||
|
||||
test("mergeShellEnv keeps explicit overrides", () => {
|
||||
const env = mergeShellEnv(
|
||||
{
|
||||
PATH: "/shell/path",
|
||||
HOME: "/tmp/home",
|
||||
},
|
||||
{
|
||||
PATH: "/desktop/path",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
},
|
||||
)
|
||||
|
||||
expect(env.PATH).toBe("/desktop/path")
|
||||
expect(env.HOME).toBe("/tmp/home")
|
||||
expect(env.OPENCODE_CLIENT).toBe("desktop")
|
||||
})
|
||||
|
||||
test("isNushell handles path and binary name", () => {
|
||||
expect(isNushell("nu")).toBe(true)
|
||||
expect(isNushell("/opt/homebrew/bin/nu")).toBe(true)
|
||||
expect(isNushell("C:\\Program Files\\nu.exe")).toBe(true)
|
||||
expect(isNushell("/bin/zsh")).toBe(false)
|
||||
})
|
||||
})
|
||||
88
packages/desktop-electron/src/main/shell-env.ts
Normal file
88
packages/desktop-electron/src/main/shell-env.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { spawnSync } from "node:child_process"
|
||||
import { basename } from "node:path"
|
||||
|
||||
const SHELL_ENV_TIMEOUT = 5_000
|
||||
|
||||
type Probe = { type: "Loaded"; value: Record<string, string> } | { type: "Timeout" } | { type: "Unavailable" }
|
||||
|
||||
export function getUserShell() {
|
||||
return process.env.SHELL || "/bin/sh"
|
||||
}
|
||||
|
||||
export function parseShellEnv(out: Buffer) {
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of out.toString("utf8").split("\0")) {
|
||||
if (!line) continue
|
||||
const ix = line.indexOf("=")
|
||||
if (ix <= 0) continue
|
||||
env[line.slice(0, ix)] = line.slice(ix + 1)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function probeShellEnv(shell: string, mode: "-il" | "-l"): Probe {
|
||||
const out = spawnSync(shell, [mode, "-c", "env -0"], {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
timeout: SHELL_ENV_TIMEOUT,
|
||||
windowsHide: true,
|
||||
})
|
||||
|
||||
const err = out.error as NodeJS.ErrnoException | undefined
|
||||
if (err) {
|
||||
if (err.code === "ETIMEDOUT") return { type: "Timeout" }
|
||||
console.log(`[cli] Shell env probe failed for ${shell} ${mode}: ${err.message}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
if (out.status !== 0) {
|
||||
console.log(`[cli] Shell env probe exited with non-zero status for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
const env = parseShellEnv(out.stdout)
|
||||
if (Object.keys(env).length === 0) {
|
||||
console.log(`[cli] Shell env probe returned empty env for ${shell} ${mode}`)
|
||||
return { type: "Unavailable" }
|
||||
}
|
||||
|
||||
return { type: "Loaded", value: env }
|
||||
}
|
||||
|
||||
export function isNushell(shell: string) {
|
||||
const name = basename(shell).toLowerCase()
|
||||
const raw = shell.toLowerCase()
|
||||
return name === "nu" || name === "nu.exe" || raw.endsWith("\\nu.exe")
|
||||
}
|
||||
|
||||
export function loadShellEnv(shell: string) {
|
||||
if (isNushell(shell)) {
|
||||
console.log(`[cli] Skipping shell env probe for nushell: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const interactive = probeShellEnv(shell, "-il")
|
||||
if (interactive.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -il (${Object.keys(interactive.value).length} vars)`)
|
||||
return interactive.value
|
||||
}
|
||||
if (interactive.type === "Timeout") {
|
||||
console.warn(`[cli] Interactive shell env probe timed out: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const login = probeShellEnv(shell, "-l")
|
||||
if (login.type === "Loaded") {
|
||||
console.log(`[cli] Loaded shell environment with -l (${Object.keys(login.value).length} vars)`)
|
||||
return login.value
|
||||
}
|
||||
|
||||
console.warn(`[cli] Falling back to app environment: ${shell}`)
|
||||
return null
|
||||
}
|
||||
|
||||
export function mergeShellEnv(shell: Record<string, string> | null, env: Record<string, string>) {
|
||||
return {
|
||||
...(shell || {}),
|
||||
...env,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
@@ -175,9 +175,8 @@ export namespace Account {
|
||||
mapAccountServiceError("HTTP request failed"),
|
||||
)
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const refreshToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
const response = yield* executeEffectOk(
|
||||
HttpClientRequest.post(`${row.url}/auth/device/token`).pipe(
|
||||
@@ -208,6 +207,30 @@ export namespace Account {
|
||||
return parsed.access_token
|
||||
})
|
||||
|
||||
const refreshTokenCache = yield* Cache.make<AccountID, AccessToken, AccountError>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
timeToLive: Duration.zero,
|
||||
lookup: Effect.fnUntraced(function* (accountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) {
|
||||
return yield* Effect.fail(new AccountServiceError({ message: "Account not found during token refresh" }))
|
||||
}
|
||||
|
||||
const account = maybeAccount.value
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (account.token_expiry && account.token_expiry > now) return account.access_token
|
||||
|
||||
return yield* refreshToken(account)
|
||||
}),
|
||||
})
|
||||
|
||||
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
if (row.token_expiry && row.token_expiry > now) return row.access_token
|
||||
|
||||
return yield* Cache.get(refreshTokenCache, row.id)
|
||||
})
|
||||
|
||||
const resolveAccess = Effect.fnUntraced(function* (accountID: AccountID) {
|
||||
const maybeAccount = yield* repo.getRow(accountID)
|
||||
if (Option.isNone(maybeAccount)) return Option.none()
|
||||
|
||||
@@ -75,6 +75,7 @@ export namespace Agent {
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const skill = yield* Skill.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
@@ -330,9 +331,9 @@ export namespace Agent {
|
||||
model?: { providerID: ProviderID; modelID: ModelID }
|
||||
}) {
|
||||
const cfg = yield* config.get()
|
||||
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||
const model = input.model ?? (yield* provider.defaultModel())
|
||||
const resolved = yield* provider.getModel(model.providerID, model.modelID)
|
||||
const language = yield* provider.getLanguage(resolved)
|
||||
|
||||
const system = [PROMPT_GENERATE]
|
||||
yield* Effect.promise(() =>
|
||||
@@ -393,6 +394,7 @@ export namespace Agent {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
|
||||
@@ -46,7 +46,7 @@ export namespace Bus {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Bus.state")(function* (ctx) {
|
||||
const wildcard = yield* PubSub.unbounded<Payload>()
|
||||
const typed = new Map<string, PubSub.PubSub<Payload>>()
|
||||
@@ -82,13 +82,13 @@ export namespace Bus {
|
||||
|
||||
function publish<D extends BusEvent.Definition>(def: D, properties: z.output<D["properties"]>) {
|
||||
return Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const payload: Payload = { type: def.type, properties }
|
||||
log.info("publishing", { type: def.type })
|
||||
|
||||
const ps = state.typed.get(def.type)
|
||||
const ps = s.typed.get(def.type)
|
||||
if (ps) yield* PubSub.publish(ps, payload)
|
||||
yield* PubSub.publish(state.wildcard, payload)
|
||||
yield* PubSub.publish(s.wildcard, payload)
|
||||
|
||||
const dir = yield* InstanceState.directory
|
||||
GlobalBus.emit("event", {
|
||||
@@ -102,8 +102,8 @@ export namespace Bus {
|
||||
log.info("subscribing", { type: def.type })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const ps = yield* getOrCreate(s, def)
|
||||
return Stream.fromPubSub(ps)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: def.type }))))
|
||||
@@ -113,8 +113,8 @@ export namespace Bus {
|
||||
log.info("subscribing", { type: "*" })
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Stream.fromPubSub(state.wildcard)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Stream.fromPubSub(s.wildcard)
|
||||
}),
|
||||
).pipe(Stream.ensuring(Effect.sync(() => log.info("unsubscribing", { type: "*" }))))
|
||||
}
|
||||
@@ -150,14 +150,14 @@ export namespace Bus {
|
||||
def: D,
|
||||
callback: (event: Payload<D>) => unknown,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const ps = yield* getOrCreate(state, def)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const ps = yield* getOrCreate(s, def)
|
||||
return yield* on(ps, def.type, callback)
|
||||
})
|
||||
|
||||
const subscribeAllCallback = Effect.fn("Bus.subscribeAllCallback")(function* (callback: (event: any) => unknown) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return yield* on(state.wildcard, "*", callback)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* on(s.wildcard, "*", callback)
|
||||
})
|
||||
|
||||
return Service.of({ publish, subscribe, subscribeAll, subscribeCallback, subscribeAllCallback })
|
||||
|
||||
@@ -57,7 +57,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
return agents()
|
||||
},
|
||||
current() {
|
||||
return agents().find((x) => x.name === agentStore.current)!
|
||||
return agents().find((x) => x.name === agentStore.current) ?? agents()[0]
|
||||
},
|
||||
set(name: string) {
|
||||
if (!agents().some((x) => x.name === name))
|
||||
|
||||
@@ -85,7 +85,7 @@ export namespace Command {
|
||||
|
||||
commands[Default.INIT] = {
|
||||
name: Default.INIT,
|
||||
description: "create/update AGENTS.md",
|
||||
description: "guided AGENTS.md setup",
|
||||
source: "command",
|
||||
get template() {
|
||||
return PROMPT_INITIALIZE.replace("${path}", ctx.worktree)
|
||||
@@ -161,16 +161,16 @@ export namespace Command {
|
||||
}
|
||||
})
|
||||
|
||||
const cache = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||
const state = yield* InstanceState.make<State>((ctx) => init(ctx))
|
||||
|
||||
const get = Effect.fn("Command.get")(function* (name: string) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return state.commands[name]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.commands[name]
|
||||
})
|
||||
|
||||
const list = Effect.fn("Command.list")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Object.values(state.commands)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Object.values(s.commands)
|
||||
})
|
||||
|
||||
return Service.of({ get, list })
|
||||
|
||||
@@ -1,10 +1,66 @@
|
||||
Please analyze this codebase and create an AGENTS.md file containing:
|
||||
1. Build/lint/test commands - especially for running a single test
|
||||
2. Code style guidelines including imports, formatting, types, naming conventions, error handling, etc.
|
||||
Create or update `AGENTS.md` for this repository.
|
||||
|
||||
The file you create will be given to agentic coding agents (such as yourself) that operate in this repository. Make it about 150 lines long.
|
||||
If there are Cursor rules (in .cursor/rules/ or .cursorrules) or Copilot rules (in .github/copilot-instructions.md), make sure to include them.
|
||||
|
||||
If there's already an AGENTS.md, improve it if it's located in ${path}
|
||||
The goal is a compact instruction file that helps future OpenCode sessions avoid mistakes and ramp up quickly. Every line should answer: "Would an agent likely miss this without help?" If not, leave it out.
|
||||
|
||||
User-provided focus or constraints (honor these):
|
||||
$ARGUMENTS
|
||||
|
||||
## How to investigate
|
||||
|
||||
Read the highest-value sources first:
|
||||
- `README*`, root manifests, workspace config, lockfiles
|
||||
- build, test, lint, formatter, typecheck, and codegen config
|
||||
- CI workflows and pre-commit / task runner config
|
||||
- existing instruction files (`AGENTS.md`, `CLAUDE.md`, `.cursor/rules/`, `.cursorrules`, `.github/copilot-instructions.md`)
|
||||
- repo-local OpenCode config such as `opencode.json`
|
||||
|
||||
If architecture is still unclear after reading config and docs, inspect a small number of representative code files to find the real entrypoints, package boundaries, and execution flow. Prefer reading the files that explain how the system is wired together over random leaf files.
|
||||
|
||||
Prefer executable sources of truth over prose. If docs conflict with config or scripts, trust the executable source and only keep what you can verify.
|
||||
|
||||
## What to extract
|
||||
|
||||
Look for the highest-signal facts for an agent working in this repo:
|
||||
- exact developer commands, especially non-obvious ones
|
||||
- how to run a single test, a single package, or a focused verification step
|
||||
- required command order when it matters, such as `lint -> typecheck -> test`
|
||||
- monorepo or multi-package boundaries, ownership of major directories, and the real app/library entrypoints
|
||||
- framework or toolchain quirks: generated code, migrations, codegen, build artifacts, special env loading, dev servers, infra deploy flow
|
||||
- repo-specific style or workflow conventions that differ from defaults
|
||||
- testing quirks: fixtures, integration test prerequisites, snapshot workflows, required services, flaky or expensive suites
|
||||
- important constraints from existing instruction files worth preserving
|
||||
|
||||
Good `AGENTS.md` content is usually hard-earned context that took reading multiple files to infer.
|
||||
|
||||
## Questions
|
||||
|
||||
Only ask the user questions if the repo cannot answer something important. Use the `question` tool for one short batch at most.
|
||||
|
||||
Good questions:
|
||||
- undocumented team conventions
|
||||
- branch / PR / release expectations
|
||||
- missing setup or test prerequisites that are known but not written down
|
||||
|
||||
Do not ask about anything the repo already makes clear.
|
||||
|
||||
## Writing rules
|
||||
|
||||
Include only high-signal, repo-specific guidance such as:
|
||||
- exact commands and shortcuts the agent would otherwise guess wrong
|
||||
- architecture notes that are not obvious from filenames
|
||||
- conventions that differ from language or framework defaults
|
||||
- setup requirements, environment quirks, and operational gotchas
|
||||
- references to existing instruction sources that matter
|
||||
|
||||
Exclude:
|
||||
- generic software advice
|
||||
- long tutorials or exhaustive file trees
|
||||
- obvious language conventions
|
||||
- speculative claims or anything you could not verify
|
||||
- content better stored in another file referenced via `opencode.json` `instructions`
|
||||
|
||||
When in doubt, omit.
|
||||
|
||||
Prefer short sections and bullets. If the repo is simple, keep the file simple. If the repo is large, summarize the few structural facts that actually change how an agent should work.
|
||||
|
||||
If `AGENTS.md` already exists at `${path}`, improve it in place rather than rewriting blindly. Preserve verified useful guidance, delete fluff or stale claims, and reconcile it with the current codebase.
|
||||
|
||||
@@ -386,9 +386,17 @@ export const make = Effect.gen(function* () {
|
||||
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
|
||||
return yield* Effect.void
|
||||
}
|
||||
return yield* kill((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
|
||||
const send = (s: NodeJS.Signals) =>
|
||||
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
||||
const sig = command.options.killSignal ?? "SIGTERM"
|
||||
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
||||
const escalated = command.options.forceKillAfter
|
||||
? Effect.timeoutOrElse(attempt, {
|
||||
duration: command.options.forceKillAfter,
|
||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
: attempt
|
||||
return yield* Effect.ignore(escalated)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -413,14 +421,17 @@ export const make = Effect.gen(function* () {
|
||||
),
|
||||
)
|
||||
}),
|
||||
kill: (opts?: ChildProcess.KillOptions) =>
|
||||
timeout(
|
||||
proc,
|
||||
command,
|
||||
opts,
|
||||
)((command, proc, signal) =>
|
||||
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
|
||||
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
kill: (opts?: ChildProcess.KillOptions) => {
|
||||
const sig = opts?.killSignal ?? "SIGTERM"
|
||||
const send = (s: NodeJS.Signals) =>
|
||||
Effect.catch(killGroup(command, proc, s), () => killOne(command, proc, s))
|
||||
const attempt = send(sig).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid)
|
||||
if (!opts?.forceKillAfter) return attempt
|
||||
return Effect.timeoutOrElse(attempt, {
|
||||
duration: opts.forceKillAfter,
|
||||
orElse: () => send("SIGKILL").pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
case "PipedCommand": {
|
||||
|
||||
@@ -24,9 +24,9 @@ export namespace InstanceState {
|
||||
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
||||
}
|
||||
|
||||
export const context = Effect.fnUntraced(function* () {
|
||||
export const context = Effect.gen(function* () {
|
||||
return (yield* InstanceRef) ?? Instance.current
|
||||
})()
|
||||
})
|
||||
|
||||
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||
|
||||
@@ -37,9 +37,9 @@ export namespace InstanceState {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
capacity: Number.POSITIVE_INFINITY,
|
||||
lookup: () =>
|
||||
Effect.fnUntraced(function* () {
|
||||
Effect.gen(function* () {
|
||||
return yield* init(yield* context)
|
||||
})(),
|
||||
}),
|
||||
})
|
||||
|
||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||
|
||||
@@ -5,7 +5,6 @@ import { AppFileSystem } from "@/filesystem"
|
||||
import { git } from "@/util/git"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { formatPatch, structuredPatch } from "diff"
|
||||
import fs from "fs"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import ignore from "ignore"
|
||||
import path from "path"
|
||||
@@ -359,49 +358,46 @@ export namespace File {
|
||||
const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
|
||||
const next: Entry = { files: [], dirs: [] }
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = await fs.promises
|
||||
.readdir(Instance.directory, { withFileTypes: true })
|
||||
.catch(() => [] as fs.Dirent[])
|
||||
if (isGlobalHome) {
|
||||
const dirs = new Set<string>()
|
||||
const protectedNames = Protected.names()
|
||||
const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
|
||||
const shouldIgnoreName = (name: string) => name.startsWith(".") || protectedNames.has(name)
|
||||
const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
|
||||
const top = yield* appFs.readDirectoryEntries(Instance.directory).pipe(Effect.orElseSucceed(() => []))
|
||||
|
||||
for (const entry of top) {
|
||||
if (!entry.isDirectory()) continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
for (const entry of top) {
|
||||
if (entry.type !== "directory") continue
|
||||
if (shouldIgnoreName(entry.name)) continue
|
||||
dirs.add(entry.name + "/")
|
||||
|
||||
const base = path.join(Instance.directory, entry.name)
|
||||
const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
|
||||
for (const child of children) {
|
||||
if (!child.isDirectory()) continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const seen = new Set<string>()
|
||||
for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
const base = path.join(Instance.directory, entry.name)
|
||||
const children = yield* appFs.readDirectoryEntries(base).pipe(Effect.orElseSucceed(() => []))
|
||||
for (const child of children) {
|
||||
if (child.type !== "directory") continue
|
||||
if (shouldIgnoreNested(child.name)) continue
|
||||
dirs.add(entry.name + "/" + child.name + "/")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
next.dirs = Array.from(dirs).toSorted()
|
||||
} else {
|
||||
const files = yield* Effect.promise(() => Array.fromAsync(Ripgrep.files({ cwd: Instance.directory })))
|
||||
const seen = new Set<string>()
|
||||
for (const file of files) {
|
||||
next.files.push(file)
|
||||
let current = file
|
||||
while (true) {
|
||||
const dir = path.dirname(current)
|
||||
if (dir === ".") break
|
||||
if (dir === current) break
|
||||
current = dir
|
||||
if (seen.has(dir)) continue
|
||||
seen.add(dir)
|
||||
next.dirs.push(dir + "/")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const s = yield* InstanceState.get(state)
|
||||
s.cache = next
|
||||
@@ -636,30 +632,27 @@ export namespace File {
|
||||
yield* ensure()
|
||||
const { cache } = yield* InstanceState.get(state)
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
const query = input.query.trim()
|
||||
const limit = input.limit ?? 100
|
||||
const kind = input.type ?? (input.dirs === false ? "file" : "all")
|
||||
log.info("search", { query, kind })
|
||||
|
||||
const result = cache
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
const preferHidden = query.startsWith(".") || query.includes("/.")
|
||||
|
||||
if (!query) {
|
||||
if (kind === "file") return result.files.slice(0, limit)
|
||||
return sortHiddenLast(result.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
if (!query) {
|
||||
if (kind === "file") return cache.files.slice(0, limit)
|
||||
return sortHiddenLast(cache.dirs.toSorted(), preferHidden).slice(0, limit)
|
||||
}
|
||||
|
||||
const items =
|
||||
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
|
||||
const items =
|
||||
kind === "file" ? cache.files : kind === "directory" ? cache.dirs : [...cache.files, ...cache.dirs]
|
||||
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
|
||||
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((item) => item.target)
|
||||
const output = kind === "directory" ? sortHiddenLast(sorted, preferHidden).slice(0, limit) : sorted
|
||||
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
})
|
||||
log.info("search", { query, kind, results: output.length })
|
||||
return output
|
||||
})
|
||||
|
||||
log.info("init")
|
||||
|
||||
@@ -477,7 +477,7 @@ export namespace MCP {
|
||||
})
|
||||
}
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("MCP.state")(function* () {
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
@@ -549,7 +549,7 @@ export namespace MCP {
|
||||
}
|
||||
|
||||
const status = Effect.fn("MCP.status")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
@@ -564,12 +564,12 @@ export namespace MCP {
|
||||
})
|
||||
|
||||
const clients = Effect.fn("MCP.clients")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.clients
|
||||
})
|
||||
|
||||
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result = yield* create(name, mcp)
|
||||
|
||||
s.status[name] = result.status
|
||||
@@ -588,7 +588,7 @@ export namespace MCP {
|
||||
|
||||
const add = Effect.fn("MCP.add")(function* (name: string, mcp: Config.Mcp) {
|
||||
yield* createAndStore(name, mcp)
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return { status: s.status }
|
||||
})
|
||||
|
||||
@@ -602,7 +602,7 @@ export namespace MCP {
|
||||
})
|
||||
|
||||
const disconnect = Effect.fn("MCP.disconnect")(function* (name: string) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
s.status[name] = { status: "disabled" }
|
||||
@@ -610,7 +610,7 @@ export namespace MCP {
|
||||
|
||||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
const result: Record<string, Tool> = {}
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
@@ -657,12 +657,12 @@ export namespace MCP {
|
||||
}
|
||||
|
||||
const prompts = Effect.fn("MCP.prompts")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
|
||||
})
|
||||
|
||||
const resources = Effect.fn("MCP.resources")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
|
||||
})
|
||||
|
||||
@@ -672,7 +672,7 @@ export namespace MCP {
|
||||
label: string,
|
||||
meta?: Record<string, unknown>,
|
||||
) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const client = s.clients[clientName]
|
||||
if (!client) {
|
||||
log.warn(`client not found for ${label}`, { clientName })
|
||||
|
||||
@@ -103,7 +103,7 @@ export namespace Plugin {
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Plugin.state")(function* (ctx) {
|
||||
const hooks: Hooks[] = []
|
||||
|
||||
@@ -279,8 +279,8 @@ export namespace Plugin {
|
||||
Output = Parameters<Required<Hooks>[Name]>[1],
|
||||
>(name: Name, input: Input, output: Output) {
|
||||
if (!name) return output
|
||||
const state = yield* InstanceState.get(cache)
|
||||
for (const hook of state.hooks) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
for (const hook of s.hooks) {
|
||||
const fn = hook[name] as any
|
||||
if (!fn) continue
|
||||
yield* Effect.promise(async () => fn(input, output))
|
||||
@@ -289,12 +289,12 @@ export namespace Plugin {
|
||||
})
|
||||
|
||||
const list = Effect.fn("Plugin.list")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return state.hooks
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.hooks
|
||||
})
|
||||
|
||||
const init = Effect.fn("Plugin.init")(function* () {
|
||||
yield* InstanceState.get(cache)
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
return Service.of({ trigger, list, init })
|
||||
|
||||
@@ -111,26 +111,25 @@ export namespace ProviderAuth {
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ProviderAuth") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const auth = yield* Auth.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ProviderAuth.state")(() =>
|
||||
Effect.promise(async () => {
|
||||
const plugins = await Plugin.list()
|
||||
return {
|
||||
hooks: Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
Effect.fn("ProviderAuth.state")(function* () {
|
||||
const plugins = yield* plugin.list()
|
||||
return {
|
||||
hooks: Record.fromEntries(
|
||||
Arr.filterMap(plugins, (x) =>
|
||||
x.auth?.provider !== undefined
|
||||
? Result.succeed([ProviderID.make(x.auth.provider), x.auth] as const)
|
||||
: Result.failVoid,
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
pending: new Map<ProviderID, AuthOAuthResult>(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const methods = Effect.fn("ProviderAuth.methods")(function* () {
|
||||
@@ -230,7 +229,9 @@ export namespace ProviderAuth {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.defaultLayer))
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(Layer.provide(Auth.defaultLayer), Layer.provide(Plugin.defaultLayer)),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -961,13 +961,14 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service> = Layer.effect(
|
||||
const layer: Layer.Layer<Service, never, Config.Service | Auth.Service | Plugin.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(() =>
|
||||
const state = yield* InstanceState.make<State>(() =>
|
||||
Effect.gen(function* () {
|
||||
using _ = log.time("state")
|
||||
const cfg = yield* config.get()
|
||||
@@ -1128,7 +1129,7 @@ export namespace Provider {
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = yield* Effect.promise(() => Plugin.list())
|
||||
const plugins = yield* plugin.list()
|
||||
for (const plugin of plugins) {
|
||||
if (!plugin.auth) continue
|
||||
const providerID = ProviderID.make(plugin.auth.provider)
|
||||
@@ -1247,7 +1248,7 @@ export namespace Provider {
|
||||
}),
|
||||
)
|
||||
|
||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(cache, (s) => s.providers))
|
||||
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
|
||||
|
||||
async function resolveSDK(model: Model, s: State) {
|
||||
try {
|
||||
@@ -1385,11 +1386,11 @@ export namespace Provider {
|
||||
}
|
||||
|
||||
const getProvider = Effect.fn("Provider.getProvider")((providerID: ProviderID) =>
|
||||
InstanceState.use(cache, (s) => s.providers[providerID]),
|
||||
InstanceState.use(state, (s) => s.providers[providerID]),
|
||||
)
|
||||
|
||||
const getModel = Effect.fn("Provider.getModel")(function* (providerID: ProviderID, modelID: ModelID) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) {
|
||||
const available = Object.keys(s.providers)
|
||||
@@ -1407,7 +1408,7 @@ export namespace Provider {
|
||||
})
|
||||
|
||||
const getLanguage = Effect.fn("Provider.getLanguage")(function* (model: Model) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const key = `${model.providerID}/${model.id}`
|
||||
if (s.models.has(key)) return s.models.get(key)!
|
||||
|
||||
@@ -1439,7 +1440,7 @@ export namespace Provider {
|
||||
})
|
||||
|
||||
const closest = Effect.fn("Provider.closest")(function* (providerID: ProviderID, query: string[]) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
for (const item of query) {
|
||||
@@ -1458,7 +1459,7 @@ export namespace Provider {
|
||||
return yield* getModel(parsed.providerID, parsed.modelID)
|
||||
}
|
||||
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const provider = s.providers[providerID]
|
||||
if (!provider) return undefined
|
||||
|
||||
@@ -1510,7 +1511,7 @@ export namespace Provider {
|
||||
const cfg = yield* config.get()
|
||||
if (cfg.model) return parseModel(cfg.model)
|
||||
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const recent = yield* Effect.promise(() =>
|
||||
Filesystem.readJson<{
|
||||
recent?: { providerID: ProviderID; modelID: ModelID }[]
|
||||
@@ -1541,11 +1542,16 @@ export namespace Provider {
|
||||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(
|
||||
Service,
|
||||
layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Auth.defaultLayer)),
|
||||
export const defaultLayer = Layer.suspend(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function list() {
|
||||
return runPromise((svc) => svc.list())
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export namespace Pty {
|
||||
session.subscribers.clear()
|
||||
}
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Pty.state")(function* (ctx) {
|
||||
const state = {
|
||||
dir: ctx.directory,
|
||||
@@ -151,27 +151,27 @@ export namespace Pty {
|
||||
)
|
||||
|
||||
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (!session) return
|
||||
state.sessions.delete(id)
|
||||
s.sessions.delete(id)
|
||||
log.info("removing session", { id })
|
||||
teardown(session)
|
||||
void Bus.publish(Event.Deleted, { id: session.info.id })
|
||||
})
|
||||
|
||||
const list = Effect.fn("Pty.list")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return Array.from(state.sessions.values()).map((session) => session.info)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return Array.from(s.sessions.values()).map((session) => session.info)
|
||||
})
|
||||
|
||||
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
return state.sessions.get(id)?.info
|
||||
const s = yield* InstanceState.get(state)
|
||||
return s.sessions.get(id)?.info
|
||||
})
|
||||
|
||||
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const id = PtyID.ascending()
|
||||
const command = input.command || Shell.preferred()
|
||||
@@ -180,7 +180,7 @@ export namespace Pty {
|
||||
args.push("-l")
|
||||
}
|
||||
|
||||
const cwd = input.cwd || state.dir
|
||||
const cwd = input.cwd || s.dir
|
||||
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||
const env = {
|
||||
...process.env,
|
||||
@@ -221,7 +221,7 @@ export namespace Pty {
|
||||
cursor: 0,
|
||||
subscribers: new Map(),
|
||||
}
|
||||
state.sessions.set(id, session)
|
||||
s.sessions.set(id, session)
|
||||
proc.onData(
|
||||
Instance.bind((chunk) => {
|
||||
session.cursor += chunk.length
|
||||
@@ -264,8 +264,8 @@ export namespace Pty {
|
||||
})
|
||||
|
||||
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (!session) return
|
||||
if (input.title) {
|
||||
session.info.title = input.title
|
||||
@@ -278,24 +278,24 @@ export namespace Pty {
|
||||
})
|
||||
|
||||
const resize = Effect.fn("Pty.resize")(function* (id: PtyID, cols: number, rows: number) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (session && session.info.status === "running") {
|
||||
session.process.resize(cols, rows)
|
||||
}
|
||||
})
|
||||
|
||||
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (session && session.info.status === "running") {
|
||||
session.process.write(data)
|
||||
}
|
||||
})
|
||||
|
||||
const connect = Effect.fn("Pty.connect")(function* (id: PtyID, ws: Socket, cursor?: number) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const session = state.sessions.get(id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const session = s.sessions.get(id)
|
||||
if (!session) {
|
||||
ws.close()
|
||||
return
|
||||
|
||||
@@ -63,7 +63,13 @@ export namespace SessionCompaction {
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
Bus.Service | Config.Service | Session.Service | Agent.Service | Plugin.Service | SessionProcessor.Service
|
||||
| Bus.Service
|
||||
| Config.Service
|
||||
| Session.Service
|
||||
| Agent.Service
|
||||
| Plugin.Service
|
||||
| SessionProcessor.Service
|
||||
| Provider.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -73,6 +79,7 @@ export namespace SessionCompaction {
|
||||
const agents = yield* Agent.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const provider = yield* Provider.Service
|
||||
|
||||
const isOverflow = Effect.fn("SessionCompaction.isOverflow")(function* (input: {
|
||||
tokens: MessageV2.Assistant["tokens"]
|
||||
@@ -170,11 +177,9 @@ export namespace SessionCompaction {
|
||||
}
|
||||
|
||||
const agent = yield* agents.get("compaction")
|
||||
const model = yield* Effect.promise(() =>
|
||||
agent.model
|
||||
? Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: Provider.getModel(userMessage.model.providerID, userMessage.model.modelID),
|
||||
)
|
||||
const model = agent.model
|
||||
? yield* provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||
: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID)
|
||||
// Allow plugins to inject context or replace compaction prompt.
|
||||
const compacting = yield* plugin.trigger(
|
||||
"experimental.session.compacting",
|
||||
@@ -377,6 +382,7 @@ When constructing the summary, try to stick to this template:
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(SessionProcessor.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
|
||||
@@ -28,7 +28,9 @@ import { ReadTool } from "../tool/read"
|
||||
import { FileTime } from "../file/time"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { ulid } from "ulid"
|
||||
import { spawn } from "child_process"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Command } from "../command"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { ConfigMarkdown } from "../config/markdown"
|
||||
@@ -84,6 +86,7 @@ export namespace SessionPrompt {
|
||||
const status = yield* SessionStatus.Service
|
||||
const sessions = yield* Session.Service
|
||||
const agents = yield* Agent.Service
|
||||
const provider = yield* Provider.Service
|
||||
const processor = yield* SessionProcessor.Service
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
@@ -95,9 +98,10 @@ export namespace SessionPrompt {
|
||||
const filetime = yield* FileTime.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const cache = yield* InstanceState.make(
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("SessionPrompt.state")(function* () {
|
||||
const runners = new Map<string, Runner<MessageV2.WithParts>>()
|
||||
yield* Effect.addFinalizer(
|
||||
@@ -131,14 +135,14 @@ export namespace SessionPrompt {
|
||||
const assertNotBusy: (sessionID: SessionID) => Effect.Effect<void, Session.BusyError> = Effect.fn(
|
||||
"SessionPrompt.assertNotBusy",
|
||||
)(function* (sessionID: SessionID) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = s.runners.get(sessionID)
|
||||
if (runner?.busy) throw new Session.BusyError(sessionID)
|
||||
})
|
||||
|
||||
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
|
||||
log.info("cancel", { sessionID })
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = s.runners.get(sessionID)
|
||||
if (!runner || !runner.busy) {
|
||||
yield* status.set(sessionID, { type: "idle" })
|
||||
@@ -206,14 +210,14 @@ export namespace SessionPrompt {
|
||||
|
||||
const ag = yield* agents.get("title")
|
||||
if (!ag) return
|
||||
const mdl = ag.model
|
||||
? yield* provider.getModel(ag.model.providerID, ag.model.modelID)
|
||||
: ((yield* provider.getSmallModel(input.providerID)) ??
|
||||
(yield* provider.getModel(input.providerID, input.modelID)))
|
||||
const msgs = onlySubtasks
|
||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||
: yield* Effect.promise(() => MessageV2.toModelMessages(context, mdl))
|
||||
const text = yield* Effect.promise(async (signal) => {
|
||||
const mdl = ag.model
|
||||
? await Provider.getModel(ag.model.providerID, ag.model.modelID)
|
||||
: ((await Provider.getSmallModel(input.providerID)) ??
|
||||
(await Provider.getModel(input.providerID, input.modelID)))
|
||||
const msgs = onlySubtasks
|
||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||
: await MessageV2.toModelMessages(context, mdl)
|
||||
const result = await LLM.stream({
|
||||
agent: ag,
|
||||
user: firstInfo,
|
||||
@@ -808,22 +812,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
fish: { args: ["-c", input.command] },
|
||||
zsh: {
|
||||
args: [
|
||||
"-c",
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
[[ -f ~/.zshenv ]] && source ~/.zshenv >/dev/null 2>&1 || true
|
||||
[[ -f "\${ZDOTDIR:-$HOME}/.zshrc" ]] && source "\${ZDOTDIR:-$HOME}/.zshrc" >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
],
|
||||
},
|
||||
bash: {
|
||||
args: [
|
||||
"-c",
|
||||
"-l",
|
||||
"-c",
|
||||
`
|
||||
__oc_cwd=$PWD
|
||||
shopt -s expand_aliases
|
||||
[[ -f ~/.bashrc ]] && source ~/.bashrc >/dev/null 2>&1 || true
|
||||
cd "$__oc_cwd"
|
||||
eval ${JSON.stringify(input.command)}
|
||||
`,
|
||||
],
|
||||
@@ -831,7 +839,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
cmd: { args: ["/c", input.command] },
|
||||
powershell: { args: ["-NoProfile", "-Command", input.command] },
|
||||
pwsh: { args: ["-NoProfile", "-Command", input.command] },
|
||||
"": { args: ["-c", `${input.command}`] },
|
||||
"": { args: ["-c", input.command] },
|
||||
}
|
||||
|
||||
const args = (invocations[shellName] ?? invocations[""]).args
|
||||
@@ -841,51 +849,20 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||
{ env: {} },
|
||||
)
|
||||
const proc = yield* Effect.sync(() =>
|
||||
spawn(sh, args, {
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
windowsHide: process.platform === "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv.env,
|
||||
TERM: "dumb",
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const cmd = ChildProcess.make(sh, args, {
|
||||
cwd,
|
||||
extendEnv: true,
|
||||
env: { ...shellEnv.env, TERM: "dumb" },
|
||||
stdin: "ignore",
|
||||
forceKillAfter: "3 seconds",
|
||||
})
|
||||
|
||||
let output = ""
|
||||
const write = () => {
|
||||
if (part.state.status !== "running") return
|
||||
part.state.metadata = { output, description: "" }
|
||||
void Effect.runFork(sessions.updatePart(part))
|
||||
}
|
||||
|
||||
proc.stdout?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
write()
|
||||
})
|
||||
proc.stderr?.on("data", (chunk) => {
|
||||
output += chunk.toString()
|
||||
write()
|
||||
})
|
||||
|
||||
let aborted = false
|
||||
let exited = false
|
||||
let finished = false
|
||||
const kill = Effect.promise(() => Shell.killTree(proc, { exited: () => exited }))
|
||||
|
||||
const abortHandler = () => {
|
||||
if (aborted) return
|
||||
aborted = true
|
||||
void Effect.runFork(kill)
|
||||
}
|
||||
|
||||
const finish = Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
if (finished) return
|
||||
finished = true
|
||||
if (aborted) {
|
||||
output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
|
||||
}
|
||||
@@ -907,20 +884,26 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}),
|
||||
)
|
||||
|
||||
const exit = yield* Effect.promise(() => {
|
||||
signal.addEventListener("abort", abortHandler, { once: true })
|
||||
if (signal.aborted) abortHandler()
|
||||
return new Promise<void>((resolve) => {
|
||||
const close = () => {
|
||||
exited = true
|
||||
proc.off("close", close)
|
||||
resolve()
|
||||
}
|
||||
proc.once("close", close)
|
||||
})
|
||||
const exit = yield* Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(cmd)
|
||||
yield* Stream.runForEach(Stream.decodeText(handle.all), (chunk) =>
|
||||
Effect.sync(() => {
|
||||
output += chunk
|
||||
if (part.state.status === "running") {
|
||||
part.state.metadata = { output, description: "" }
|
||||
void Effect.runFork(sessions.updatePart(part))
|
||||
}
|
||||
}),
|
||||
)
|
||||
yield* handle.exitCode
|
||||
}).pipe(
|
||||
Effect.onInterrupt(() => Effect.sync(abortHandler)),
|
||||
Effect.ensuring(Effect.sync(() => signal.removeEventListener("abort", abortHandler))),
|
||||
Effect.scoped,
|
||||
Effect.onInterrupt(() =>
|
||||
Effect.sync(() => {
|
||||
aborted = true
|
||||
}),
|
||||
),
|
||||
Effect.orDie,
|
||||
Effect.ensuring(finish),
|
||||
Effect.exit,
|
||||
)
|
||||
@@ -932,21 +915,35 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return { info: msg, parts: [part] }
|
||||
})
|
||||
|
||||
const getModel = (providerID: ProviderID, modelID: ModelID, sessionID: SessionID) =>
|
||||
Effect.promise(() =>
|
||||
Provider.getModel(providerID, modelID).catch((e) => {
|
||||
if (Provider.ModelNotFoundError.isInstance(e)) {
|
||||
const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : ""
|
||||
Bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({
|
||||
message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`,
|
||||
}).toObject(),
|
||||
})
|
||||
}
|
||||
throw e
|
||||
}),
|
||||
)
|
||||
const getModel = Effect.fn("SessionPrompt.getModel")(function* (
|
||||
providerID: ProviderID,
|
||||
modelID: ModelID,
|
||||
sessionID: SessionID,
|
||||
) {
|
||||
const exit = yield* provider.getModel(providerID, modelID).pipe(Effect.exit)
|
||||
if (Exit.isSuccess(exit)) return exit.value
|
||||
const err = Cause.squash(exit.cause)
|
||||
if (Provider.ModelNotFoundError.isInstance(err)) {
|
||||
const hint = err.data.suggestions?.length ? ` Did you mean: ${err.data.suggestions.join(", ")}?` : ""
|
||||
yield* bus.publish(Session.Event.Error, {
|
||||
sessionID,
|
||||
error: new NamedError.Unknown({
|
||||
message: `Model not found: ${err.data.providerID}/${err.data.modelID}.${hint}`,
|
||||
}).toObject(),
|
||||
})
|
||||
}
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
})
|
||||
|
||||
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
const model = yield* Effect.promise(async () => {
|
||||
for await (const item of MessageV2.stream(sessionID)) {
|
||||
if (item.info.role === "user" && item.info.model) return item.info.model
|
||||
}
|
||||
})
|
||||
if (model) return model
|
||||
return yield* provider.defaultModel()
|
||||
})
|
||||
|
||||
const createUserMessage = Effect.fn("SessionPrompt.createUserMessage")(function* (input: PromptInput) {
|
||||
const agentName = input.agent || (yield* agents.defaultAgent())
|
||||
@@ -960,9 +957,12 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
}
|
||||
|
||||
const model = input.model ?? ag.model ?? (yield* lastModel(input.sessionID))
|
||||
const same = ag.model && model.providerID === ag.model.providerID && model.modelID === ag.model.modelID
|
||||
const full =
|
||||
!input.variant && ag.variant
|
||||
? yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID).catch(() => undefined))
|
||||
!input.variant && ag.variant && same
|
||||
? yield* provider
|
||||
.getModel(model.providerID, model.modelID)
|
||||
.pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
: undefined
|
||||
const variant = input.variant ?? (ag.variant && full?.variants?.[ag.variant] ? ag.variant : undefined)
|
||||
|
||||
@@ -1109,7 +1109,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
]
|
||||
const read = yield* Effect.promise(() => ReadTool.init()).pipe(
|
||||
Effect.flatMap((t) =>
|
||||
Effect.promise(() => Provider.getModel(info.model.providerID, info.model.modelID)).pipe(
|
||||
provider.getModel(info.model.providerID, info.model.modelID).pipe(
|
||||
Effect.flatMap((mdl) =>
|
||||
Effect.promise(() =>
|
||||
t.execute(args, {
|
||||
@@ -1557,14 +1557,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
const loop: (input: z.infer<typeof LoopInput>) => Effect.Effect<MessageV2.WithParts> = Effect.fn(
|
||||
"SessionPrompt.loop",
|
||||
)(function* (input: z.infer<typeof LoopInput>) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = getRunner(s.runners, input.sessionID)
|
||||
return yield* runner.ensureRunning(runLoop(input.sessionID))
|
||||
})
|
||||
|
||||
const shell: (input: ShellInput) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.shell")(
|
||||
function* (input: ShellInput) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const runner = getRunner(s.runners, input.sessionID)
|
||||
return yield* runner.startShell((signal) => shellImpl(input, signal))
|
||||
},
|
||||
@@ -1711,11 +1711,13 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
Layer.provide(FileTime.defaultLayer),
|
||||
Layer.provide(ToolRegistry.defaultLayer),
|
||||
Layer.provide(Truncate.layer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1856,15 +1858,6 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
return runPromise((svc) => svc.command(CommandInput.parse(input)))
|
||||
}
|
||||
|
||||
const lastModel = Effect.fnUntraced(function* (sessionID: SessionID) {
|
||||
return yield* Effect.promise(async () => {
|
||||
for await (const item of MessageV2.stream(sessionID)) {
|
||||
if (item.info.role === "user" && item.info.model) return item.info.model
|
||||
}
|
||||
return Provider.defaultModel()
|
||||
})
|
||||
})
|
||||
|
||||
/** @internal Exported for testing */
|
||||
export function createStructuredOutputTool(input: {
|
||||
schema: Record<string, any>
|
||||
|
||||
@@ -150,15 +150,11 @@ export namespace SessionSummary {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() =>
|
||||
layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
),
|
||||
),
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(Storage.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -57,7 +57,7 @@ export namespace ToolRegistry {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
|
||||
@@ -139,18 +139,18 @@ export namespace ToolRegistry {
|
||||
})
|
||||
|
||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const idx = state.custom.findIndex((t) => t.id === tool.id)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const idx = s.custom.findIndex((t) => t.id === tool.id)
|
||||
if (idx >= 0) {
|
||||
state.custom.splice(idx, 1, tool)
|
||||
s.custom.splice(idx, 1, tool)
|
||||
return
|
||||
}
|
||||
state.custom.push(tool)
|
||||
s.custom.push(tool)
|
||||
})
|
||||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const tools = yield* all(state.custom)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const tools = yield* all(s.custom)
|
||||
return tools.map((t) => t.id)
|
||||
})
|
||||
|
||||
@@ -158,8 +158,8 @@ export namespace ToolRegistry {
|
||||
model: { providerID: ProviderID; modelID: ModelID },
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const allTools = yield* all(state.custom)
|
||||
const s = yield* InstanceState.get(state)
|
||||
const allTools = yield* all(s.custom)
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
|
||||
@@ -148,6 +148,70 @@ it.live("token refresh persists the new token", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("concurrent config and token requests coalesce token refresh", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
yield* AccountRepo.use((r) =>
|
||||
r.persistAccount({
|
||||
id,
|
||||
email: "user@example.com",
|
||||
url: "https://one.example.com",
|
||||
accessToken: AccessToken.make("at_old"),
|
||||
refreshToken: RefreshToken.make("rt_old"),
|
||||
expiry: Date.now() - 1_000,
|
||||
orgID: Option.some(OrgID.make("org-9")),
|
||||
}),
|
||||
)
|
||||
|
||||
let refreshCalls = 0
|
||||
const client = HttpClient.make((req) =>
|
||||
Effect.promise(async () => {
|
||||
if (req.url === "https://one.example.com/auth/device/token") {
|
||||
refreshCalls += 1
|
||||
|
||||
if (refreshCalls === 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
return json(req, {
|
||||
access_token: "at_new",
|
||||
refresh_token: "rt_new",
|
||||
expires_in: 60,
|
||||
})
|
||||
}
|
||||
|
||||
return json(
|
||||
req,
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh token already used",
|
||||
},
|
||||
400,
|
||||
)
|
||||
}
|
||||
|
||||
if (req.url === "https://one.example.com/api/config") {
|
||||
return json(req, { config: { theme: "light", seats: 5 } })
|
||||
}
|
||||
|
||||
return json(req, {}, 404)
|
||||
}),
|
||||
)
|
||||
|
||||
const [cfg, token] = yield* Account.Service.use((s) =>
|
||||
Effect.all([s.config(id, OrgID.make("org-9")), s.token(id)], { concurrency: 2 }),
|
||||
).pipe(Effect.provide(live(client)))
|
||||
|
||||
expect(Option.getOrThrow(cfg)).toEqual({ theme: "light", seats: 5 })
|
||||
expect(String(Option.getOrThrow(token))).toBe("at_new")
|
||||
expect(refreshCalls).toBe(1)
|
||||
|
||||
const row = yield* AccountRepo.use((r) => r.getRow(id))
|
||||
const value = Option.getOrThrow(row)
|
||||
expect(value.access_token).toBe(AccessToken.make("at_new"))
|
||||
expect(value.refresh_token).toBe(RefreshToken.make("rt_new"))
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("config sends the selected org header", () =>
|
||||
Effect.gen(function* () {
|
||||
const id = AccountID.make("user-1")
|
||||
|
||||
81
packages/opencode/test/fake/provider.ts
Normal file
81
packages/opencode/test/fake/provider.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
|
||||
export namespace ProviderTest {
|
||||
export function model(override: Partial<Provider.Model> = {}): Provider.Model {
|
||||
const id = override.id ?? ModelID.make("gpt-5.2")
|
||||
const providerID = override.providerID ?? ProviderID.make("openai")
|
||||
return {
|
||||
id,
|
||||
providerID,
|
||||
name: "Test Model",
|
||||
capabilities: {
|
||||
toolcall: true,
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
interleaved: false,
|
||||
input: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||
output: { text: true, image: false, audio: false, video: false, pdf: false },
|
||||
},
|
||||
api: { id, url: "https://example.com", npm: "@ai-sdk/openai" },
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 200_000, output: 10_000 },
|
||||
status: "active",
|
||||
options: {},
|
||||
headers: {},
|
||||
release_date: "2025-01-01",
|
||||
...override,
|
||||
}
|
||||
}
|
||||
|
||||
export function info(override: Partial<Provider.Info> = {}, mdl = model()): Provider.Info {
|
||||
const id = override.id ?? mdl.providerID
|
||||
return {
|
||||
id,
|
||||
name: "Test Provider",
|
||||
source: "config",
|
||||
env: [],
|
||||
options: {},
|
||||
models: { [mdl.id]: mdl },
|
||||
...override,
|
||||
}
|
||||
}
|
||||
|
||||
export function fake(override: Partial<Provider.Interface> & { model?: Provider.Model; info?: Provider.Info } = {}) {
|
||||
const mdl = override.model ?? model()
|
||||
const row = override.info ?? info({}, mdl)
|
||||
return {
|
||||
model: mdl,
|
||||
info: row,
|
||||
layer: Layer.succeed(
|
||||
Provider.Service,
|
||||
Provider.Service.of({
|
||||
list: Effect.fn("TestProvider.list")(() => Effect.succeed({ [row.id]: row })),
|
||||
getProvider: Effect.fn("TestProvider.getProvider")((providerID) => {
|
||||
if (providerID === row.id) return Effect.succeed(row)
|
||||
return Effect.die(new Error(`Unknown test provider: ${providerID}`))
|
||||
}),
|
||||
getModel: Effect.fn("TestProvider.getModel")((providerID, modelID) => {
|
||||
if (providerID === row.id && modelID === mdl.id) return Effect.succeed(mdl)
|
||||
return Effect.die(new Error(`Unknown test model: ${providerID}/${modelID}`))
|
||||
}),
|
||||
getLanguage: Effect.fn("TestProvider.getLanguage")(() =>
|
||||
Effect.die(new Error("ProviderTest.getLanguage not configured")),
|
||||
),
|
||||
closest: Effect.fn("TestProvider.closest")((providerID) =>
|
||||
Effect.succeed(providerID === row.id ? { providerID: row.id, modelID: mdl.id } : undefined),
|
||||
),
|
||||
getSmallModel: Effect.fn("TestProvider.getSmallModel")((providerID) =>
|
||||
Effect.succeed(providerID === row.id ? mdl : undefined),
|
||||
),
|
||||
defaultModel: Effect.fn("TestProvider.defaultModel")(() =>
|
||||
Effect.succeed({ providerID: row.id, modelID: mdl.id }),
|
||||
),
|
||||
...override,
|
||||
}),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,183 +1,604 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { NodeHttpServer, NodeHttpServerRequest } from "@effect/platform-node"
|
||||
import * as Http from "node:http"
|
||||
import { Deferred, Effect, Layer, ServiceMap, Stream } from "effect"
|
||||
import * as HttpServer from "effect/unstable/http/HttpServer"
|
||||
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
|
||||
type Step =
|
||||
| {
|
||||
type: "text"
|
||||
text: string
|
||||
}
|
||||
| {
|
||||
type: "tool"
|
||||
tool: string
|
||||
input: unknown
|
||||
}
|
||||
| {
|
||||
type: "fail"
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
type: "hang"
|
||||
}
|
||||
| {
|
||||
type: "hold"
|
||||
text: string
|
||||
wait: PromiseLike<unknown>
|
||||
}
|
||||
export type Usage = { input: number; output: number }
|
||||
|
||||
type Line = Record<string, unknown>
|
||||
|
||||
type Flow =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "reason"; text: string }
|
||||
| { type: "tool-start"; id: string; name: string }
|
||||
| { type: "tool-args"; text: string }
|
||||
| { type: "usage"; usage: Usage }
|
||||
|
||||
type Hit = {
|
||||
url: URL
|
||||
body: Record<string, unknown>
|
||||
}
|
||||
|
||||
type Match = (hit: Hit) => boolean
|
||||
|
||||
type Queue = {
|
||||
item: Item
|
||||
match?: Match
|
||||
}
|
||||
|
||||
type Wait = {
|
||||
count: number
|
||||
ready: Deferred.Deferred<void>
|
||||
}
|
||||
|
||||
function sse(lines: unknown[]) {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromIterable([
|
||||
[...lines.map((line) => `data: ${JSON.stringify(line)}`), "data: [DONE]"].join("\n\n") + "\n\n",
|
||||
]).pipe(Stream.encodeText),
|
||||
{ contentType: "text/event-stream" },
|
||||
)
|
||||
type Sse = {
|
||||
type: "sse"
|
||||
head: unknown[]
|
||||
tail: unknown[]
|
||||
wait?: PromiseLike<unknown>
|
||||
hang?: boolean
|
||||
error?: unknown
|
||||
reset?: boolean
|
||||
}
|
||||
|
||||
function text(step: Extract<Step, { type: "text" }>) {
|
||||
return sse([
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: { role: "assistant" } }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: { content: step.text } }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||
},
|
||||
])
|
||||
type HttpError = {
|
||||
type: "http-error"
|
||||
status: number
|
||||
body: unknown
|
||||
}
|
||||
|
||||
function tool(step: Extract<Step, { type: "tool" }>, seq: number) {
|
||||
const id = `call_${seq}`
|
||||
const args = JSON.stringify(step.input)
|
||||
return sse([
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: { role: "assistant" } }],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
export type Item = Sse | HttpError
|
||||
|
||||
const done = Symbol("done")
|
||||
|
||||
function line(input: unknown) {
|
||||
if (input === done) return "data: [DONE]\n\n"
|
||||
return `data: ${JSON.stringify(input)}\n\n`
|
||||
}
|
||||
|
||||
function tokens(input?: Usage) {
|
||||
if (!input) return
|
||||
return {
|
||||
prompt_tokens: input.input,
|
||||
completion_tokens: input.output,
|
||||
total_tokens: input.input + input.output,
|
||||
}
|
||||
}
|
||||
|
||||
function chunk(input: { delta?: Record<string, unknown>; finish?: string; usage?: Usage }) {
|
||||
return {
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
delta: input.delta ?? {},
|
||||
...(input.finish ? { finish_reason: input.finish } : {}),
|
||||
},
|
||||
],
|
||||
...(input.usage ? { usage: tokens(input.usage) } : {}),
|
||||
} satisfies Line
|
||||
}
|
||||
|
||||
function role() {
|
||||
return chunk({ delta: { role: "assistant" } })
|
||||
}
|
||||
|
||||
function textLine(value: string) {
|
||||
return chunk({ delta: { content: value } })
|
||||
}
|
||||
|
||||
function reasonLine(value: string) {
|
||||
return chunk({ delta: { reasoning_content: value } })
|
||||
}
|
||||
|
||||
function finishLine(reason: string, usage?: Usage) {
|
||||
return chunk({ finish: reason, usage })
|
||||
}
|
||||
|
||||
function toolStartLine(id: string, name: string) {
|
||||
return chunk({
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
id,
|
||||
type: "function",
|
||||
function: {
|
||||
name: step.tool,
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
id,
|
||||
type: "function",
|
||||
function: {
|
||||
name,
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
})
|
||||
}
|
||||
|
||||
function toolArgsLine(value: string) {
|
||||
return chunk({
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: args,
|
||||
},
|
||||
},
|
||||
],
|
||||
index: 0,
|
||||
function: {
|
||||
arguments: value,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: {}, finish_reason: "tool_calls" }],
|
||||
})
|
||||
}
|
||||
|
||||
function bytes(input: Iterable<unknown>) {
|
||||
return Stream.fromIterable([...input].map(line)).pipe(Stream.encodeText)
|
||||
}
|
||||
|
||||
function responseCreated(model: string) {
|
||||
return {
|
||||
type: "response.created",
|
||||
sequence_number: 1,
|
||||
response: {
|
||||
id: "resp_test",
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
model,
|
||||
service_tier: null,
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function fail(step: Extract<Step, { type: "fail" }>) {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromIterable([
|
||||
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
||||
]).pipe(Stream.encodeText, Stream.concat(Stream.fail(new Error(step.message)))),
|
||||
{ contentType: "text/event-stream" },
|
||||
)
|
||||
function responseCompleted(input: { seq: number; usage?: Usage }) {
|
||||
return {
|
||||
type: "response.completed",
|
||||
sequence_number: input.seq,
|
||||
response: {
|
||||
incomplete_details: null,
|
||||
service_tier: null,
|
||||
usage: {
|
||||
input_tokens: input.usage?.input ?? 0,
|
||||
input_tokens_details: { cached_tokens: null },
|
||||
output_tokens: input.usage?.output ?? 0,
|
||||
output_tokens_details: { reasoning_tokens: null },
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function hang() {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromIterable([
|
||||
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
||||
]).pipe(Stream.encodeText, Stream.concat(Stream.never)),
|
||||
{ contentType: "text/event-stream" },
|
||||
)
|
||||
function responseMessage(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "message", id },
|
||||
}
|
||||
}
|
||||
|
||||
function hold(step: Extract<Step, { type: "hold" }>) {
|
||||
return HttpServerResponse.stream(
|
||||
Stream.fromIterable([
|
||||
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
||||
]).pipe(
|
||||
Stream.encodeText,
|
||||
Stream.concat(
|
||||
Stream.fromEffect(Effect.promise(() => step.wait)).pipe(
|
||||
Stream.flatMap(() =>
|
||||
Stream.fromIterable([
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: { content: step.text } }],
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-test",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||
})}\n\n`,
|
||||
"data: [DONE]\n\n",
|
||||
]).pipe(Stream.encodeText),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
{ contentType: "text/event-stream" },
|
||||
)
|
||||
function responseText(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_text.delta",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
delta: text,
|
||||
logprobs: null,
|
||||
}
|
||||
}
|
||||
|
||||
function responseMessageDone(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "message", id },
|
||||
}
|
||||
}
|
||||
|
||||
function responseReason(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id, encrypted_content: null },
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonPart(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.reasoning_summary_part.added",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
summary_index: 0,
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonText(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.reasoning_summary_text.delta",
|
||||
sequence_number: seq,
|
||||
item_id: id,
|
||||
summary_index: 0,
|
||||
delta: text,
|
||||
}
|
||||
}
|
||||
|
||||
function responseReasonDone(id: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: { type: "reasoning", id, encrypted_content: null },
|
||||
}
|
||||
}
|
||||
|
||||
function responseTool(id: string, item: string, name: string, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.added",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: item,
|
||||
call_id: id,
|
||||
name,
|
||||
arguments: "",
|
||||
status: "in_progress",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function responseToolArgs(id: string, text: string, seq: number) {
|
||||
return {
|
||||
type: "response.function_call_arguments.delta",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item_id: id,
|
||||
delta: text,
|
||||
}
|
||||
}
|
||||
|
||||
function responseToolDone(tool: { id: string; item: string; name: string; args: string }, seq: number) {
|
||||
return {
|
||||
type: "response.output_item.done",
|
||||
sequence_number: seq,
|
||||
output_index: 0,
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: tool.item,
|
||||
call_id: tool.id,
|
||||
name: tool.name,
|
||||
arguments: tool.args,
|
||||
status: "completed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function choices(part: unknown) {
|
||||
if (!part || typeof part !== "object") return
|
||||
if (!("choices" in part) || !Array.isArray(part.choices)) return
|
||||
const choice = part.choices[0]
|
||||
if (!choice || typeof choice !== "object") return
|
||||
return choice
|
||||
}
|
||||
|
||||
function flow(item: Sse) {
|
||||
const out: Flow[] = []
|
||||
for (const part of [...item.head, ...item.tail]) {
|
||||
const choice = choices(part)
|
||||
const delta =
|
||||
choice && "delta" in choice && choice.delta && typeof choice.delta === "object" ? choice.delta : undefined
|
||||
|
||||
if (delta && "content" in delta && typeof delta.content === "string") {
|
||||
out.push({ type: "text", text: delta.content })
|
||||
}
|
||||
|
||||
if (delta && "reasoning_content" in delta && typeof delta.reasoning_content === "string") {
|
||||
out.push({ type: "reason", text: delta.reasoning_content })
|
||||
}
|
||||
|
||||
if (delta && "tool_calls" in delta && Array.isArray(delta.tool_calls)) {
|
||||
for (const tool of delta.tool_calls) {
|
||||
if (!tool || typeof tool !== "object") continue
|
||||
const fn = "function" in tool && tool.function && typeof tool.function === "object" ? tool.function : undefined
|
||||
if ("id" in tool && typeof tool.id === "string" && fn && "name" in fn && typeof fn.name === "string") {
|
||||
out.push({ type: "tool-start", id: tool.id, name: fn.name })
|
||||
}
|
||||
if (fn && "arguments" in fn && typeof fn.arguments === "string" && fn.arguments) {
|
||||
out.push({ type: "tool-args", text: fn.arguments })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (part && typeof part === "object" && "usage" in part && part.usage && typeof part.usage === "object") {
|
||||
const raw = part.usage as Record<string, unknown>
|
||||
if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
|
||||
out.push({
|
||||
type: "usage",
|
||||
usage: { input: raw.prompt_tokens, output: raw.completion_tokens },
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function responses(item: Sse, model: string) {
|
||||
let seq = 1
|
||||
let msg: string | undefined
|
||||
let reason: string | undefined
|
||||
let hasMsg = false
|
||||
let hasReason = false
|
||||
let call:
|
||||
| {
|
||||
id: string
|
||||
item: string
|
||||
name: string
|
||||
args: string
|
||||
}
|
||||
| undefined
|
||||
let usage: Usage | undefined
|
||||
const lines: unknown[] = [responseCreated(model)]
|
||||
|
||||
for (const part of flow(item)) {
|
||||
if (part.type === "text") {
|
||||
msg ??= "msg_1"
|
||||
if (!hasMsg) {
|
||||
hasMsg = true
|
||||
seq += 1
|
||||
lines.push(responseMessage(msg, seq))
|
||||
}
|
||||
seq += 1
|
||||
lines.push(responseText(msg, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "reason") {
|
||||
reason ||= "rs_1"
|
||||
if (!hasReason) {
|
||||
hasReason = true
|
||||
seq += 1
|
||||
lines.push(responseReason(reason, seq))
|
||||
seq += 1
|
||||
lines.push(responseReasonPart(reason, seq))
|
||||
}
|
||||
seq += 1
|
||||
lines.push(responseReasonText(reason, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "tool-start") {
|
||||
call ||= { id: part.id, item: "fc_1", name: part.name, args: "" }
|
||||
seq += 1
|
||||
lines.push(responseTool(call.id, call.item, call.name, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
if (part.type === "tool-args") {
|
||||
if (!call) continue
|
||||
call.args += part.text
|
||||
seq += 1
|
||||
lines.push(responseToolArgs(call.item, part.text, seq))
|
||||
continue
|
||||
}
|
||||
|
||||
usage = part.usage
|
||||
}
|
||||
|
||||
if (msg) {
|
||||
seq += 1
|
||||
lines.push(responseMessageDone(msg, seq))
|
||||
}
|
||||
if (reason) {
|
||||
seq += 1
|
||||
lines.push(responseReasonDone(reason, seq))
|
||||
}
|
||||
if (call && !item.hang && !item.error) {
|
||||
seq += 1
|
||||
lines.push(responseToolDone(call, seq))
|
||||
}
|
||||
if (!item.hang && !item.error) lines.push(responseCompleted({ seq: seq + 1, usage }))
|
||||
return { ...item, head: lines, tail: [] } satisfies Sse
|
||||
}
|
||||
|
||||
function modelFrom(body: unknown) {
|
||||
if (!body || typeof body !== "object") return "test-model"
|
||||
if (!("model" in body) || typeof body.model !== "string") return "test-model"
|
||||
return body.model
|
||||
}
|
||||
|
||||
function send(item: Sse) {
|
||||
const head = bytes(item.head)
|
||||
const tail = bytes([...item.tail, ...(item.hang || item.error ? [] : [done])])
|
||||
const empty = Stream.fromIterable<Uint8Array>([])
|
||||
const wait = item.wait
|
||||
const body: Stream.Stream<Uint8Array, unknown> = wait
|
||||
? Stream.concat(head, Stream.fromEffect(Effect.promise(() => wait)).pipe(Stream.flatMap(() => tail)))
|
||||
: Stream.concat(head, tail)
|
||||
let end: Stream.Stream<Uint8Array, unknown> = empty
|
||||
if (item.error) end = Stream.concat(empty, Stream.fail(item.error))
|
||||
else if (item.hang) end = Stream.concat(empty, Stream.never)
|
||||
|
||||
return HttpServerResponse.stream(Stream.concat(body, end), { contentType: "text/event-stream" })
|
||||
}
|
||||
|
||||
const reset = Effect.fn("TestLLMServer.reset")(function* (item: Sse) {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const res = NodeHttpServerRequest.toServerResponse(req)
|
||||
yield* Effect.sync(() => {
|
||||
res.writeHead(200, { "content-type": "text/event-stream" })
|
||||
for (const part of item.head) res.write(line(part))
|
||||
for (const part of item.tail) res.write(line(part))
|
||||
res.destroy(new Error("connection reset"))
|
||||
})
|
||||
return yield* Effect.never
|
||||
})
|
||||
|
||||
function fail(item: HttpError) {
|
||||
return HttpServerResponse.text(JSON.stringify(item.body), {
|
||||
status: item.status,
|
||||
contentType: "application/json",
|
||||
})
|
||||
}
|
||||
|
||||
export class Reply {
|
||||
#head: unknown[] = [role()]
|
||||
#tail: unknown[] = []
|
||||
#usage: Usage | undefined
|
||||
#finish: string | undefined
|
||||
#wait: PromiseLike<unknown> | undefined
|
||||
#hang = false
|
||||
#error: unknown
|
||||
#reset = false
|
||||
#seq = 0
|
||||
|
||||
#id() {
|
||||
this.#seq += 1
|
||||
return `call_${this.#seq}`
|
||||
}
|
||||
|
||||
text(value: string) {
|
||||
this.#tail = [...this.#tail, textLine(value)]
|
||||
return this
|
||||
}
|
||||
|
||||
reason(value: string) {
|
||||
this.#tail = [...this.#tail, reasonLine(value)]
|
||||
return this
|
||||
}
|
||||
|
||||
usage(value: Usage) {
|
||||
this.#usage = value
|
||||
return this
|
||||
}
|
||||
|
||||
wait(value: PromiseLike<unknown>) {
|
||||
this.#wait = value
|
||||
return this
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.#finish = "stop"
|
||||
this.#hang = false
|
||||
this.#error = undefined
|
||||
this.#reset = false
|
||||
return this
|
||||
}
|
||||
|
||||
toolCalls() {
|
||||
this.#finish = "tool_calls"
|
||||
this.#hang = false
|
||||
this.#error = undefined
|
||||
this.#reset = false
|
||||
return this
|
||||
}
|
||||
|
||||
tool(name: string, input: unknown) {
|
||||
const id = this.#id()
|
||||
const args = JSON.stringify(input)
|
||||
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args)]
|
||||
return this.toolCalls()
|
||||
}
|
||||
|
||||
pendingTool(name: string, input: unknown) {
|
||||
const id = this.#id()
|
||||
const args = JSON.stringify(input)
|
||||
const size = Math.max(1, Math.floor(args.length / 2))
|
||||
this.#tail = [...this.#tail, toolStartLine(id, name), toolArgsLine(args.slice(0, size))]
|
||||
return this
|
||||
}
|
||||
|
||||
hang() {
|
||||
this.#finish = undefined
|
||||
this.#hang = true
|
||||
this.#error = undefined
|
||||
this.#reset = false
|
||||
return this
|
||||
}
|
||||
|
||||
streamError(error: unknown = "boom") {
|
||||
this.#finish = undefined
|
||||
this.#hang = false
|
||||
this.#error = error
|
||||
this.#reset = false
|
||||
return this
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.#finish = undefined
|
||||
this.#hang = false
|
||||
this.#error = undefined
|
||||
this.#reset = true
|
||||
return this
|
||||
}
|
||||
|
||||
item(): Item {
|
||||
return {
|
||||
type: "sse",
|
||||
head: this.#head,
|
||||
tail: this.#finish ? [...this.#tail, finishLine(this.#finish, this.#usage)] : this.#tail,
|
||||
wait: this.#wait,
|
||||
hang: this.#hang,
|
||||
error: this.#error,
|
||||
reset: this.#reset,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reply() {
|
||||
return new Reply()
|
||||
}
|
||||
|
||||
export function httpError(status: number, body: unknown): Item {
|
||||
return {
|
||||
type: "http-error",
|
||||
status,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
export function raw(input: {
|
||||
chunks?: unknown[]
|
||||
head?: unknown[]
|
||||
tail?: unknown[]
|
||||
wait?: PromiseLike<unknown>
|
||||
hang?: boolean
|
||||
error?: unknown
|
||||
reset?: boolean
|
||||
}): Item {
|
||||
return {
|
||||
type: "sse",
|
||||
head: input.head ?? input.chunks ?? [],
|
||||
tail: input.tail ?? [],
|
||||
wait: input.wait,
|
||||
hang: input.hang,
|
||||
error: input.error,
|
||||
reset: input.reset,
|
||||
}
|
||||
}
|
||||
|
||||
function item(input: Item | Reply) {
|
||||
return input instanceof Reply ? input.item() : input
|
||||
}
|
||||
|
||||
function hit(url: string, body: unknown) {
|
||||
return {
|
||||
url: new URL(url, "http://localhost"),
|
||||
body: body && typeof body === "object" ? (body as Record<string, unknown>) : {},
|
||||
} satisfies Hit
|
||||
}
|
||||
|
||||
namespace TestLLMServer {
|
||||
export interface Service {
|
||||
readonly url: string
|
||||
readonly text: (value: string) => Effect.Effect<void>
|
||||
readonly tool: (tool: string, input: unknown) => Effect.Effect<void>
|
||||
readonly fail: (message?: string) => 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 tool: (name: string, input: unknown) => Effect.Effect<void>
|
||||
readonly toolHang: (name: string, input: unknown) => Effect.Effect<void>
|
||||
readonly reason: (value: string, opts?: { text?: string; usage?: Usage }) => Effect.Effect<void>
|
||||
readonly fail: (message?: unknown) => Effect.Effect<void>
|
||||
readonly error: (status: number, body: unknown) => Effect.Effect<void>
|
||||
readonly hang: Effect.Effect<void>
|
||||
readonly hold: (text: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
||||
readonly hold: (value: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
||||
readonly hits: Effect.Effect<Hit[]>
|
||||
readonly calls: Effect.Effect<number>
|
||||
readonly wait: (count: number) => Effect.Effect<void>
|
||||
@@ -194,12 +615,15 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
const router = yield* HttpRouter.HttpRouter
|
||||
|
||||
let hits: Hit[] = []
|
||||
let list: Step[] = []
|
||||
let seq = 0
|
||||
let list: Queue[] = []
|
||||
let waits: Wait[] = []
|
||||
|
||||
const push = (step: Step) => {
|
||||
list = [...list, step]
|
||||
const queue = (...input: (Item | Reply)[]) => {
|
||||
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* () {
|
||||
@@ -209,37 +633,33 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
|
||||
})
|
||||
|
||||
const pull = () => {
|
||||
const step = list[0]
|
||||
if (!step) return { step: undefined, seq }
|
||||
seq += 1
|
||||
list = list.slice(1)
|
||||
return { step, seq }
|
||||
const pull = (hit: Hit) => {
|
||||
const index = list.findIndex((entry) => !entry.match || entry.match(hit))
|
||||
if (index === -1) return
|
||||
const first = list[index]
|
||||
list = [...list.slice(0, index), ...list.slice(index + 1)]
|
||||
return first.item
|
||||
}
|
||||
|
||||
yield* router.add(
|
||||
"POST",
|
||||
"/v1/chat/completions",
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const next = pull()
|
||||
if (!next.step) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||
const json = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||
hits = [
|
||||
...hits,
|
||||
{
|
||||
url: new URL(req.originalUrl, "http://localhost"),
|
||||
body: json && typeof json === "object" ? (json as Record<string, unknown>) : {},
|
||||
},
|
||||
]
|
||||
yield* notify()
|
||||
if (next.step.type === "text") return text(next.step)
|
||||
if (next.step.type === "tool") return tool(next.step, next.seq)
|
||||
if (next.step.type === "fail") return fail(next.step)
|
||||
if (next.step.type === "hang") return hang()
|
||||
return hold(next.step)
|
||||
}),
|
||||
)
|
||||
const handle = Effect.fn("TestLLMServer.handle")(function* (mode: "chat" | "responses") {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const body = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||
const current = hit(req.originalUrl, body)
|
||||
const next = pull(current)
|
||||
if (!next) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||
hits = [...hits, current]
|
||||
yield* notify()
|
||||
if (next.type !== "sse") return fail(next)
|
||||
if (mode === "responses") return send(responses(next, modelFrom(body)))
|
||||
if (next.reset) {
|
||||
yield* reset(next)
|
||||
return HttpServerResponse.empty()
|
||||
}
|
||||
return send(next)
|
||||
})
|
||||
|
||||
yield* router.add("POST", "/v1/chat/completions", handle("chat"))
|
||||
yield* router.add("POST", "/v1/responses", handle("responses"))
|
||||
|
||||
yield* server.serve(router.asHttpEffect())
|
||||
|
||||
@@ -248,20 +668,52 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
server.address._tag === "TcpAddress"
|
||||
? `http://127.0.0.1:${server.address.port}/v1`
|
||||
: `unix://${server.address.path}/v1`,
|
||||
text: Effect.fn("TestLLMServer.text")(function* (value: string) {
|
||||
push({ type: "text", text: value })
|
||||
push: Effect.fn("TestLLMServer.push")(function* (...input: (Item | Reply)[]) {
|
||||
queue(...input)
|
||||
}),
|
||||
tool: Effect.fn("TestLLMServer.tool")(function* (tool: string, input: unknown) {
|
||||
push({ type: "tool", tool, input })
|
||||
pushMatch: Effect.fn("TestLLMServer.pushMatch")(function* (match: Match, ...input: (Item | Reply)[]) {
|
||||
queueMatch(match, ...input)
|
||||
}),
|
||||
fail: Effect.fn("TestLLMServer.fail")(function* (message = "boom") {
|
||||
push({ type: "fail", message })
|
||||
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 }) {
|
||||
const out = reply().text(value)
|
||||
if (opts?.usage) out.usage(opts.usage)
|
||||
queue(out.stop().item())
|
||||
}),
|
||||
tool: Effect.fn("TestLLMServer.tool")(function* (name: string, input: unknown) {
|
||||
queue(reply().tool(name, input).item())
|
||||
}),
|
||||
toolHang: Effect.fn("TestLLMServer.toolHang")(function* (name: string, input: unknown) {
|
||||
queue(reply().pendingTool(name, input).hang().item())
|
||||
}),
|
||||
reason: Effect.fn("TestLLMServer.reason")(function* (value: string, opts?: { text?: string; usage?: Usage }) {
|
||||
const out = reply().reason(value)
|
||||
if (opts?.text) out.text(opts.text)
|
||||
if (opts?.usage) out.usage(opts.usage)
|
||||
queue(out.stop().item())
|
||||
}),
|
||||
fail: Effect.fn("TestLLMServer.fail")(function* (message: unknown = "boom") {
|
||||
queue(reply().streamError(message).item())
|
||||
}),
|
||||
error: Effect.fn("TestLLMServer.error")(function* (status: number, body: unknown) {
|
||||
queue(httpError(status, body))
|
||||
}),
|
||||
hang: Effect.gen(function* () {
|
||||
push({ type: "hang" })
|
||||
queue(reply().hang().item())
|
||||
}).pipe(Effect.withSpan("TestLLMServer.hang")),
|
||||
hold: Effect.fn("TestLLMServer.hold")(function* (text: string, wait: PromiseLike<unknown>) {
|
||||
push({ type: "hold", text, wait })
|
||||
hold: Effect.fn("TestLLMServer.hold")(function* (value: string, wait: PromiseLike<unknown>) {
|
||||
queue(reply().wait(wait).text(value).stop().item())
|
||||
}),
|
||||
hits: Effect.sync(() => [...hits]),
|
||||
calls: Effect.sync(() => hits.length),
|
||||
@@ -275,8 +727,5 @@ export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServ
|
||||
pending: Effect.sync(() => list.length),
|
||||
})
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(HttpRouter.layer), //
|
||||
Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })),
|
||||
)
|
||||
).pipe(Layer.provide(HttpRouter.layer), Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { APICallError } from "ai"
|
||||
import { Cause, Effect, Exit, Layer, ManagedRuntime } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
@@ -20,9 +20,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import * as ProviderModule from "../../src/provider/provider"
|
||||
import * as SessionProcessorModule from "../../src/session/processor"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { ProviderTest } from "../fake/provider"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -65,6 +65,8 @@ function createModel(opts: {
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
const wide = () => ProviderTest.fake({ model: createModel({ context: 100_000, output: 32_000 }) })
|
||||
|
||||
async function user(sessionID: SessionID, text: string) {
|
||||
const msg = await Session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
@@ -162,10 +164,11 @@ function layer(result: "continue" | "compact") {
|
||||
)
|
||||
}
|
||||
|
||||
function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer) {
|
||||
function runtime(result: "continue" | "compact", plugin = Plugin.defaultLayer, provider = ProviderTest.fake()) {
|
||||
const bus = Bus.layer
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer, bus).pipe(
|
||||
Layer.provide(provider.layer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(layer(result)),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
@@ -198,12 +201,13 @@ function llm() {
|
||||
}
|
||||
}
|
||||
|
||||
function liveRuntime(layer: Layer.Layer<LLM.Service>) {
|
||||
function liveRuntime(layer: Layer.Layer<LLM.Service>, provider = ProviderTest.fake()) {
|
||||
const bus = Bus.layer
|
||||
const status = SessionStatus.layer.pipe(Layer.provide(bus))
|
||||
const processor = SessionProcessorModule.SessionProcessor.layer
|
||||
return ManagedRuntime.make(
|
||||
Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe(
|
||||
Layer.provide(provider.layer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Snapshot.defaultLayer),
|
||||
Layer.provide(layer),
|
||||
@@ -544,14 +548,12 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const done = defer()
|
||||
let seen = false
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
let unsub: (() => void) | undefined
|
||||
try {
|
||||
unsub = await rt.runPromise(
|
||||
@@ -596,11 +598,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = runtime("compact")
|
||||
const rt = runtime("compact", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -636,11 +636,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -678,8 +676,6 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "root")
|
||||
const replay = await user(session.id, "image")
|
||||
@@ -693,7 +689,7 @@ describe("session.compaction.process", () => {
|
||||
url: "https://example.com/cat.png",
|
||||
})
|
||||
const msg = await user(session.id, "current")
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -728,13 +724,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
await user(session.id, "earlier")
|
||||
const msg = await user(session.id, "current")
|
||||
|
||||
const rt = runtime("continue")
|
||||
const rt = runtime("continue", Plugin.defaultLayer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const result = await rt.runPromise(
|
||||
@@ -790,13 +784,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const abort = new AbortController()
|
||||
const rt = liveRuntime(stub.layer)
|
||||
const rt = liveRuntime(stub.layer, wide())
|
||||
let off: (() => void) | undefined
|
||||
let run: Promise<"continue" | "stop"> | undefined
|
||||
try {
|
||||
@@ -866,13 +858,11 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
const abort = new AbortController()
|
||||
const rt = runtime("continue", plugin(ready))
|
||||
const rt = runtime("continue", plugin(ready), wide())
|
||||
let run: Promise<"continue" | "stop"> | undefined
|
||||
try {
|
||||
run = rt
|
||||
@@ -970,11 +960,9 @@ describe("session.compaction.process", () => {
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
spyOn(ProviderModule.Provider, "getModel").mockResolvedValue(createModel({ context: 100_000, output: 32_000 }))
|
||||
|
||||
const session = await Session.create({})
|
||||
const msg = await user(session.id, "hello")
|
||||
const rt = liveRuntime(stub.layer)
|
||||
const rt = liveRuntime(stub.layer, wide())
|
||||
try {
|
||||
const msgs = await Session.messages({ sessionID: session.id })
|
||||
await rt.runPromise(
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect } from "bun:test"
|
||||
import { APICallError } from "ai"
|
||||
import { Cause, Effect, Exit, Fiber, Layer, ServiceMap } from "effect"
|
||||
import * as Stream from "effect/Stream"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
@@ -10,7 +8,7 @@ import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session } from "../../src/session"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
@@ -21,8 +19,9 @@ import { SessionStatus } from "../../src/session/status"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Log } from "../../src/util/log"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { provideTmpdirServer } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { reply, TestLLMServer } from "../lib/llm-server"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
@@ -31,118 +30,51 @@ const ref = {
|
||||
modelID: ModelID.make("test-model"),
|
||||
}
|
||||
|
||||
type Script = Stream.Stream<LLM.Event, unknown> | ((input: LLM.StreamInput) => Stream.Stream<LLM.Event, unknown>)
|
||||
|
||||
class TestLLM extends ServiceMap.Service<
|
||||
TestLLM,
|
||||
{
|
||||
readonly push: (stream: Script) => Effect.Effect<void>
|
||||
readonly reply: (...items: LLM.Event[]) => Effect.Effect<void>
|
||||
readonly calls: Effect.Effect<number>
|
||||
readonly inputs: Effect.Effect<LLM.StreamInput[]>
|
||||
}
|
||||
>()("@test/SessionProcessorLLM") {}
|
||||
|
||||
function stream(...items: LLM.Event[]) {
|
||||
return Stream.make(...items)
|
||||
const cfg = {
|
||||
provider: {
|
||||
test: {
|
||||
name: "Test",
|
||||
id: "test",
|
||||
env: [],
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
models: {
|
||||
"test-model": {
|
||||
id: "test-model",
|
||||
name: "Test Model",
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: false,
|
||||
tool_call: true,
|
||||
release_date: "2025-01-01",
|
||||
limit: { context: 100000, output: 10000 },
|
||||
cost: { input: 0, output: 0 },
|
||||
options: {},
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: "http://localhost:1/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
function usage(input = 1, output = 1, total = input + output) {
|
||||
function providerCfg(url: string) {
|
||||
return {
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
totalTokens: total,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
...cfg,
|
||||
provider: {
|
||||
...cfg.provider,
|
||||
test: {
|
||||
...cfg.provider.test,
|
||||
options: {
|
||||
...cfg.provider.test.options,
|
||||
baseURL: url,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function start(): LLM.Event {
|
||||
return { type: "start" }
|
||||
}
|
||||
|
||||
function textStart(id = "t"): LLM.Event {
|
||||
return { type: "text-start", id }
|
||||
}
|
||||
|
||||
function textDelta(id: string, text: string): LLM.Event {
|
||||
return { type: "text-delta", id, text }
|
||||
}
|
||||
|
||||
function textEnd(id = "t"): LLM.Event {
|
||||
return { type: "text-end", id }
|
||||
}
|
||||
|
||||
function reasoningStart(id: string): LLM.Event {
|
||||
return { type: "reasoning-start", id }
|
||||
}
|
||||
|
||||
function reasoningDelta(id: string, text: string): LLM.Event {
|
||||
return { type: "reasoning-delta", id, text }
|
||||
}
|
||||
|
||||
function reasoningEnd(id: string): LLM.Event {
|
||||
return { type: "reasoning-end", id }
|
||||
}
|
||||
|
||||
function finishStep(): LLM.Event {
|
||||
return {
|
||||
type: "finish-step",
|
||||
finishReason: "stop",
|
||||
rawFinishReason: "stop",
|
||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
||||
providerMetadata: undefined,
|
||||
usage: usage(),
|
||||
}
|
||||
}
|
||||
|
||||
function finish(): LLM.Event {
|
||||
return { type: "finish", finishReason: "stop", rawFinishReason: "stop", totalUsage: usage() }
|
||||
}
|
||||
|
||||
function toolInputStart(id: string, toolName: string): LLM.Event {
|
||||
return { type: "tool-input-start", id, toolName }
|
||||
}
|
||||
|
||||
function toolCall(toolCallId: string, toolName: string, input: unknown): LLM.Event {
|
||||
return { type: "tool-call", toolCallId, toolName, input }
|
||||
}
|
||||
|
||||
function fail<E>(err: E, ...items: LLM.Event[]) {
|
||||
return stream(...items).pipe(Stream.concat(Stream.fail(err)))
|
||||
}
|
||||
|
||||
function hang(_input: LLM.StreamInput, ...items: LLM.Event[]) {
|
||||
return stream(...items).pipe(Stream.concat(Stream.fromEffect(Effect.never)))
|
||||
}
|
||||
|
||||
function model(context: number): Provider.Model {
|
||||
return {
|
||||
id: "test-model",
|
||||
providerID: "test",
|
||||
name: "Test",
|
||||
limit: { context, output: 10 },
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
capabilities: {
|
||||
toolcall: true,
|
||||
attachment: false,
|
||||
reasoning: false,
|
||||
temperature: true,
|
||||
input: { text: true, image: false, audio: false, video: false },
|
||||
output: { text: true, image: false, audio: false, video: false },
|
||||
},
|
||||
api: { npm: "@ai-sdk/anthropic" },
|
||||
options: {},
|
||||
} as Provider.Model
|
||||
}
|
||||
|
||||
function agent(): Agent.Info {
|
||||
return {
|
||||
name: "build",
|
||||
@@ -211,43 +143,6 @@ const assistant = Effect.fn("TestSession.assistant")(function* (
|
||||
return msg
|
||||
})
|
||||
|
||||
const llm = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const queue: Script[] = []
|
||||
const inputs: LLM.StreamInput[] = []
|
||||
let calls = 0
|
||||
|
||||
const push = Effect.fn("TestLLM.push")((item: Script) => {
|
||||
queue.push(item)
|
||||
return Effect.void
|
||||
})
|
||||
|
||||
const reply = Effect.fn("TestLLM.reply")((...items: LLM.Event[]) => push(stream(...items)))
|
||||
return Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
LLM.Service,
|
||||
LLM.Service.of({
|
||||
stream: (input) => {
|
||||
calls += 1
|
||||
inputs.push(input)
|
||||
const item = queue.shift() ?? Stream.empty
|
||||
return typeof item === "function" ? item(input) : item
|
||||
},
|
||||
}),
|
||||
),
|
||||
Layer.succeed(
|
||||
TestLLM,
|
||||
TestLLM.of({
|
||||
push,
|
||||
reply,
|
||||
calls: Effect.sync(() => calls),
|
||||
inputs: Effect.sync(() => [...inputs]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
const status = SessionStatus.layer.pipe(Layer.provideMerge(Bus.layer))
|
||||
const infra = Layer.mergeAll(NodeFileSystem.layer, CrossSpawnSpawner.defaultLayer)
|
||||
const deps = Layer.mergeAll(
|
||||
@@ -257,27 +152,37 @@ const deps = Layer.mergeAll(
|
||||
Permission.layer,
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
LLM.defaultLayer,
|
||||
Provider.defaultLayer,
|
||||
status,
|
||||
llm,
|
||||
).pipe(Layer.provideMerge(infra))
|
||||
const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
||||
const env = Layer.mergeAll(TestLLMServer.layer, SessionProcessor.layer.pipe(Layer.provideMerge(deps)))
|
||||
|
||||
const it = testEffect(env)
|
||||
|
||||
it.live("session.processor effect tests capture llm input cleanly", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const boot = Effect.fn("test.boot")(function* () {
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const provider = yield* Provider.Service
|
||||
return { processors, session, provider }
|
||||
})
|
||||
|
||||
yield* test.reply(start(), textStart(), textDelta("t", "hello"), textEnd(), finishStep(), finish())
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
it.live("session.processor effect tests capture llm input cleanly", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.text("hello")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "hi")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -303,46 +208,29 @@ it.live("session.processor effect tests capture llm input cleanly", () => {
|
||||
|
||||
const value = yield* handle.process(input)
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||
const calls = yield* test.calls
|
||||
const inputs = yield* test.inputs
|
||||
const calls = yield* llm.calls
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(calls).toBe(1)
|
||||
expect(inputs).toHaveLength(1)
|
||||
expect(inputs[0].messages).toStrictEqual([{ role: "user", content: "hi" }])
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "hello")).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests stop after token overflow requests compaction", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests stop after token overflow requests compaction", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* test.reply(
|
||||
start(),
|
||||
{
|
||||
type: "finish-step",
|
||||
finishReason: "stop",
|
||||
rawFinishReason: "stop",
|
||||
response: { id: "res", modelId: "test-model", timestamp: new Date() },
|
||||
providerMetadata: undefined,
|
||||
usage: usage(100, 0, 100),
|
||||
},
|
||||
textStart(),
|
||||
textDelta("t", "after"),
|
||||
textEnd(),
|
||||
)
|
||||
yield* llm.text("after", { usage: { input: 100, output: 0 } })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "compact")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(20)
|
||||
const base = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const mdl = { ...base, limit: { context: 20, output: 10 } }
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -369,51 +257,73 @@ it.live("session.processor effect tests stop after token overflow requests compa
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||
|
||||
expect(value).toBe("compact")
|
||||
expect(parts.some((part) => part.type === "text")).toBe(false)
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||
expect(parts.some((part) => part.type === "step-finish")).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests reset reasoning state across retries", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests capture reasoning from http mock", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* test.push(
|
||||
fail(
|
||||
new APICallError({
|
||||
message: "boom",
|
||||
url: "https://example.com/v1/chat/completions",
|
||||
requestBodyValues: {},
|
||||
statusCode: 503,
|
||||
responseHeaders: { "retry-after-ms": "0" },
|
||||
responseBody: '{"error":"boom"}',
|
||||
isRetryable: true,
|
||||
}),
|
||||
start(),
|
||||
reasoningStart("r"),
|
||||
reasoningDelta("r", "one"),
|
||||
),
|
||||
)
|
||||
|
||||
yield* test.reply(
|
||||
start(),
|
||||
reasoningStart("r"),
|
||||
reasoningDelta("r", "two"),
|
||||
reasoningEnd("r"),
|
||||
finishStep(),
|
||||
finish(),
|
||||
)
|
||||
yield* llm.push(reply().reason("think").text("done").stop())
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "reason")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
})
|
||||
|
||||
const value = yield* handle.process({
|
||||
user: {
|
||||
id: parent.id,
|
||||
sessionID: chat.id,
|
||||
role: "user",
|
||||
time: parent.time,
|
||||
agent: parent.agent,
|
||||
model: { providerID: ref.providerID, modelID: ref.modelID },
|
||||
} satisfies MessageV2.User,
|
||||
sessionID: chat.id,
|
||||
model: mdl,
|
||||
agent: agent(),
|
||||
system: [],
|
||||
messages: [{ role: "user", content: "reason" }],
|
||||
tools: {},
|
||||
})
|
||||
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||
const reasoning = parts.find((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||
const text = parts.find((part): part is MessageV2.TextPart => part.type === "text")
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(reasoning?.text).toBe("think")
|
||||
expect(text?.text).toBe("done")
|
||||
}),
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests reset reasoning state across retries", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* llm.push(reply().reason("one").reset(), reply().reason("two").stop())
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "reason")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -441,28 +351,26 @@ it.live("session.processor effect tests reset reasoning state across retries", (
|
||||
const reasoning = parts.filter((part): part is MessageV2.ReasoningPart => part.type === "reasoning")
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* test.calls).toBe(2)
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(reasoning.some((part) => part.text === "two")).toBe(true)
|
||||
expect(reasoning.some((part) => part.text === "onetwo")).toBe(false)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests do not retry unknown json errors", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests do not retry unknown json errors", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* test.push(fail({ error: { message: "no_kv_space" } }, start()))
|
||||
yield* llm.error(400, { error: { message: "no_kv_space" } })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "json")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -487,29 +395,26 @@ it.live("session.processor effect tests do not retry unknown json errors", () =>
|
||||
})
|
||||
|
||||
expect(value).toBe("stop")
|
||||
expect(yield* test.calls).toBe(1)
|
||||
expect(yield* test.inputs).toHaveLength(1)
|
||||
expect(handle.message.error?.name).toBe("UnknownError")
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(handle.message.error?.name).toBe("APIError")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests retry recognized structured json errors", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests retry recognized structured json errors", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* test.push(fail({ type: "error", error: { type: "too_many_requests" } }, start()))
|
||||
yield* test.reply(start(), textStart(), textDelta("t", "after"), textEnd(), finishStep(), finish())
|
||||
yield* llm.error(429, { type: "error", error: { type: "too_many_requests" } })
|
||||
yield* llm.text("after")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "retry json")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -536,43 +441,28 @@ it.live("session.processor effect tests retry recognized structured json errors"
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* test.calls).toBe(2)
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
|
||||
expect(handle.message.error).toBeUndefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests publish retry status updates", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests publish retry status updates", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
yield* test.push(
|
||||
fail(
|
||||
new APICallError({
|
||||
message: "boom",
|
||||
url: "https://example.com/v1/chat/completions",
|
||||
requestBodyValues: {},
|
||||
statusCode: 503,
|
||||
responseHeaders: { "retry-after-ms": "0" },
|
||||
responseBody: '{"error":"boom"}',
|
||||
isRetryable: true,
|
||||
}),
|
||||
start(),
|
||||
),
|
||||
)
|
||||
yield* test.reply(start(), finishStep(), finish())
|
||||
yield* llm.error(503, { error: "boom" })
|
||||
yield* llm.text("")
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "retry")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const states: number[] = []
|
||||
const off = yield* bus.subscribeCallback(SessionStatus.Event.Status, (evt) => {
|
||||
if (evt.properties.sessionID !== chat.id) return
|
||||
@@ -604,27 +494,25 @@ it.live("session.processor effect tests publish retry status updates", () => {
|
||||
off()
|
||||
|
||||
expect(value).toBe("continue")
|
||||
expect(yield* test.calls).toBe(2)
|
||||
expect(yield* llm.calls).toBe(2)
|
||||
expect(states).toStrictEqual([1])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests compact on structured context overflow", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests compact on structured context overflow", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* test.push(fail({ type: "error", error: { code: "context_length_exceeded" } }, start()))
|
||||
yield* llm.error(400, { type: "error", error: { code: "context_length_exceeded" } })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "compact json")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -649,32 +537,25 @@ it.live("session.processor effect tests compact on structured context overflow",
|
||||
})
|
||||
|
||||
expect(value).toBe("compact")
|
||||
expect(yield* test.calls).toBe(1)
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(handle.message.error).toBeUndefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const ready = defer<void>()
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
|
||||
yield* test.push((input) =>
|
||||
hang(input, start(), toolInputStart("tool-1", "bash"), toolCall("tool-1", "bash", { cmd: "pwd" })).pipe(
|
||||
Stream.tap((event) => (event.type === "tool-call" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
||||
),
|
||||
)
|
||||
yield* llm.toolHang("bash", { cmd: "pwd" })
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "tool abort")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -700,7 +581,15 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* Effect.promise(() => ready.promise)
|
||||
yield* llm.wait(1)
|
||||
yield* Effect.promise(async () => {
|
||||
const end = Date.now() + 500
|
||||
while (Date.now() < end) {
|
||||
const parts = await MessageV2.parts(msg.id)
|
||||
if (parts.some((part) => part.type === "tool")) return
|
||||
await Bun.sleep(10)
|
||||
}
|
||||
})
|
||||
yield* Fiber.interrupt(run)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
@@ -708,45 +597,38 @@ it.live("session.processor effect tests mark pending tools as aborted on cleanup
|
||||
yield* handle.abort()
|
||||
}
|
||||
const parts = yield* Effect.promise(() => MessageV2.parts(msg.id))
|
||||
const tool = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
||||
const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool")
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
|
||||
}
|
||||
expect(yield* test.calls).toBe(1)
|
||||
expect(tool?.state.status).toBe("error")
|
||||
if (tool?.state.status === "error") {
|
||||
expect(tool.state.error).toBe("Tool execution aborted")
|
||||
expect(tool.state.time.end).toBeDefined()
|
||||
expect(yield* llm.calls).toBe(1)
|
||||
expect(call?.state.status).toBe("error")
|
||||
if (call?.state.status === "error") {
|
||||
expect(call.state.error).toBe("Tool execution aborted")
|
||||
expect(call.state.time.end).toBeDefined()
|
||||
}
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests record aborted errors and idle state", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests record aborted errors and idle state", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const ready = defer<void>()
|
||||
const seen = defer<void>()
|
||||
const test = yield* TestLLM
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const bus = yield* Bus.Service
|
||||
const status = yield* SessionStatus.Service
|
||||
const sts = yield* SessionStatus.Service
|
||||
|
||||
yield* test.push((input) =>
|
||||
hang(input, start()).pipe(
|
||||
Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
||||
),
|
||||
)
|
||||
yield* llm.hang
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "abort")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const errs: string[] = []
|
||||
const off = yield* bus.subscribeCallback(Session.Event.Error, (evt) => {
|
||||
if (evt.properties.sessionID !== chat.id) return
|
||||
@@ -779,7 +661,7 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* Effect.promise(() => ready.promise)
|
||||
yield* llm.wait(1)
|
||||
yield* Fiber.interrupt(run)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
@@ -788,7 +670,7 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
||||
}
|
||||
yield* Effect.promise(() => seen.promise)
|
||||
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
||||
const state = yield* status.get(chat.id)
|
||||
const state = yield* sts.get(chat.id)
|
||||
off()
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
@@ -803,30 +685,23 @@ it.live("session.processor effect tests record aborted errors and idle state", (
|
||||
expect(state).toMatchObject({ type: "idle" })
|
||||
expect(errs).toContain("MessageAbortedError")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("session.processor effect tests mark interruptions aborted without manual abort", () => {
|
||||
return provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.live("session.processor effect tests mark interruptions aborted without manual abort", () =>
|
||||
provideTmpdirServer(
|
||||
({ dir, llm }) =>
|
||||
Effect.gen(function* () {
|
||||
const ready = defer<void>()
|
||||
const processors = yield* SessionProcessor.Service
|
||||
const session = yield* Session.Service
|
||||
const status = yield* SessionStatus.Service
|
||||
const test = yield* TestLLM
|
||||
const { processors, session, provider } = yield* boot()
|
||||
const sts = yield* SessionStatus.Service
|
||||
|
||||
yield* test.push((input) =>
|
||||
hang(input, start()).pipe(
|
||||
Stream.tap((event) => (event.type === "start" ? Effect.sync(() => ready.resolve()) : Effect.void)),
|
||||
),
|
||||
)
|
||||
yield* llm.hang
|
||||
|
||||
const chat = yield* session.create({})
|
||||
const parent = yield* user(chat.id, "interrupt")
|
||||
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
|
||||
const mdl = model(100)
|
||||
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
|
||||
const handle = yield* processors.create({
|
||||
assistantMessage: msg,
|
||||
sessionID: chat.id,
|
||||
@@ -852,12 +727,12 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
||||
})
|
||||
.pipe(Effect.forkChild)
|
||||
|
||||
yield* Effect.promise(() => ready.promise)
|
||||
yield* llm.wait(1)
|
||||
yield* Fiber.interrupt(run)
|
||||
|
||||
const exit = yield* Fiber.await(run)
|
||||
const stored = yield* Effect.promise(() => MessageV2.get({ sessionID: chat.id, messageID: msg.id }))
|
||||
const state = yield* status.get(chat.id)
|
||||
const state = yield* sts.get(chat.id)
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
expect(handle.message.error?.name).toBe("MessageAbortedError")
|
||||
@@ -867,6 +742,6 @@ it.live("session.processor effect tests mark interruptions aborted without manua
|
||||
}
|
||||
expect(state).toMatchObject({ type: "idle" })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
{ git: true, config: (url) => providerCfg(url) },
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,247 +0,0 @@
|
||||
import { describe, expect, spyOn, test } from "bun:test"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { Session } from "../../src/session"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import { SessionStatus } from "../../src/session/status"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
Log.init({ print: false })
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
// Helper: seed a session with a user message + finished assistant message
|
||||
// so loop() exits immediately without calling any LLM
|
||||
async function seed(sessionID: SessionID) {
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
})
|
||||
|
||||
const assistantMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: userMsg.id,
|
||||
sessionID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: "gpt-5.2" as any,
|
||||
providerID: "openai" as any,
|
||||
time: { created: Date.now(), completed: Date.now() },
|
||||
finish: "stop",
|
||||
}
|
||||
await Session.updateMessage(assistantMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID,
|
||||
type: "text",
|
||||
text: "hi there",
|
||||
})
|
||||
|
||||
return { userMsg, assistantMsg }
|
||||
}
|
||||
|
||||
describe("session.prompt concurrency", () => {
|
||||
test("loop returns assistant message and sets status to idle", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
await seed(session.id)
|
||||
|
||||
const result = await SessionPrompt.loop({ sessionID: session.id })
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") expect(result.info.finish).toBe("stop")
|
||||
|
||||
const status = await SessionStatus.get(session.id)
|
||||
expect(status.type).toBe("idle")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("concurrent loop callers get the same result", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
await seed(session.id)
|
||||
|
||||
const [a, b] = await Promise.all([
|
||||
SessionPrompt.loop({ sessionID: session.id }),
|
||||
SessionPrompt.loop({ sessionID: session.id }),
|
||||
])
|
||||
|
||||
expect(a.info.id).toBe(b.info.id)
|
||||
expect(a.info.role).toBe("assistant")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("assertNotBusy throws when loop is running", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: session.id,
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
})
|
||||
|
||||
const ready = deferred()
|
||||
const gate = deferred()
|
||||
const getModel = spyOn(Provider, "getModel").mockImplementation(async () => {
|
||||
ready.resolve()
|
||||
await gate.promise
|
||||
throw new Error("test stop")
|
||||
})
|
||||
|
||||
try {
|
||||
const loopPromise = SessionPrompt.loop({ sessionID: session.id }).catch(() => undefined)
|
||||
await ready.promise
|
||||
|
||||
await expect(SessionPrompt.assertNotBusy(session.id)).rejects.toBeInstanceOf(Session.BusyError)
|
||||
|
||||
gate.resolve()
|
||||
await loopPromise
|
||||
} finally {
|
||||
gate.resolve()
|
||||
getModel.mockRestore()
|
||||
}
|
||||
|
||||
// After loop completes, assertNotBusy should succeed
|
||||
await SessionPrompt.assertNotBusy(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("cancel sets status to idle", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
// Seed only a user message — loop must call getModel to proceed
|
||||
const userMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: session.id,
|
||||
time: { created: Date.now() },
|
||||
agent: "build",
|
||||
model: { providerID: "openai" as any, modelID: "gpt-5.2" as any },
|
||||
}
|
||||
await Session.updateMessage(userMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: userMsg.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "hello",
|
||||
})
|
||||
// Also seed an assistant message so lastAssistant() fallback can find it
|
||||
const assistantMsg: MessageV2.Info = {
|
||||
id: MessageID.ascending(),
|
||||
role: "assistant",
|
||||
parentID: userMsg.id,
|
||||
sessionID: session.id,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
cost: 0,
|
||||
path: { cwd: "/tmp", root: "/tmp" },
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
modelID: "gpt-5.2" as any,
|
||||
providerID: "openai" as any,
|
||||
time: { created: Date.now() },
|
||||
}
|
||||
await Session.updateMessage(assistantMsg)
|
||||
await Session.updatePart({
|
||||
id: PartID.ascending(),
|
||||
messageID: assistantMsg.id,
|
||||
sessionID: session.id,
|
||||
type: "text",
|
||||
text: "hi there",
|
||||
})
|
||||
|
||||
const ready = deferred()
|
||||
const gate = deferred()
|
||||
const getModel = spyOn(Provider, "getModel").mockImplementation(async () => {
|
||||
ready.resolve()
|
||||
await gate.promise
|
||||
throw new Error("test stop")
|
||||
})
|
||||
|
||||
try {
|
||||
// Start loop — it will block in getModel (assistant has no finish, so loop continues)
|
||||
const loopPromise = SessionPrompt.loop({ sessionID: session.id })
|
||||
|
||||
await ready.promise
|
||||
|
||||
await SessionPrompt.cancel(session.id)
|
||||
|
||||
const status = await SessionStatus.get(session.id)
|
||||
expect(status.type).toBe("idle")
|
||||
|
||||
// loop should resolve cleanly, not throw "All fibers interrupted"
|
||||
const result = await loopPromise
|
||||
expect(result.info.role).toBe("assistant")
|
||||
expect(result.info.id).toBe(assistantMsg.id)
|
||||
} finally {
|
||||
gate.resolve()
|
||||
getModel.mockRestore()
|
||||
}
|
||||
},
|
||||
})
|
||||
}, 10000)
|
||||
|
||||
test("cancel on idle session just sets idle", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Session.create({})
|
||||
await SessionPrompt.cancel(session.id)
|
||||
const status = await SessionStatus.get(session.id)
|
||||
expect(status.type).toBe("idle")
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { expect, spyOn } from "bun:test"
|
||||
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
@@ -12,6 +13,7 @@ import { LSP } from "../../src/lsp"
|
||||
import { MCP } from "../../src/mcp"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||
import type { Provider } from "../../src/provider/provider"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { Session } from "../../src/session"
|
||||
@@ -151,6 +153,7 @@ function makeHttp() {
|
||||
Permission.layer,
|
||||
Plugin.defaultLayer,
|
||||
Config.defaultLayer,
|
||||
ProviderSvc.defaultLayer,
|
||||
filetime,
|
||||
lsp,
|
||||
mcp,
|
||||
@@ -885,6 +888,79 @@ unix("shell captures stdout and stderr in completed tool output", () =>
|
||||
),
|
||||
)
|
||||
|
||||
unix("shell completes a fast command on the preferred shell", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, chat } = yield* boot()
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "pwd",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.input.command).toBe("pwd")
|
||||
expect(tool.state.output).toContain(dir)
|
||||
expect(tool.state.metadata.output).toContain(dir)
|
||||
yield* prompt.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
unix("shell lists files from the project directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, chat } = yield* boot()
|
||||
yield* Effect.promise(() => Bun.write(path.join(dir, "README.md"), "# e2e\n"))
|
||||
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "command ls",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.input.command).toBe("command ls")
|
||||
expect(tool.state.output).toContain("README.md")
|
||||
expect(tool.state.metadata.output).toContain("README.md")
|
||||
yield* prompt.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
unix("shell captures stderr from a failing command", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const { prompt, chat } = yield* boot()
|
||||
const result = yield* prompt.shell({
|
||||
sessionID: chat.id,
|
||||
agent: "build",
|
||||
command: "command -v __nonexistent_cmd_e2e__ || echo 'not found' >&2; exit 1",
|
||||
})
|
||||
|
||||
expect(result.info.role).toBe("assistant")
|
||||
const tool = completedTool(result.parts)
|
||||
if (!tool) return
|
||||
|
||||
expect(tool.state.output).toContain("not found")
|
||||
expect(tool.state.metadata.output).toContain("not found")
|
||||
yield* prompt.assertNotBusy(chat.id)
|
||||
}),
|
||||
{ git: true, config: cfg },
|
||||
),
|
||||
)
|
||||
|
||||
unix(
|
||||
"shell updates running metadata before process exit",
|
||||
() =>
|
||||
|
||||
@@ -7,11 +7,12 @@ export function DockPrompt(props: {
|
||||
children: JSX.Element
|
||||
footer: JSX.Element
|
||||
ref?: (el: HTMLDivElement) => void
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
}) {
|
||||
const slot = (name: string) => `${props.kind}-${name}`
|
||||
|
||||
return (
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref}>
|
||||
<div data-component="dock-prompt" data-kind={props.kind} ref={props.ref} onKeyDown={props.onKeyDown}>
|
||||
<DockShell data-slot={slot("body")}>
|
||||
<div data-slot={slot("header")}>{props.header}</div>
|
||||
<div data-slot={slot("content")}>{props.children}</div>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render as renderSolid } from "solid-js/web"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { createHoverCommentUtility } from "../pierre/comment-hover"
|
||||
import { cloneSelectedLineRange, formatSelectedLineLabel, lineInSelectedRange } from "../pierre/selection-bridge"
|
||||
import { LineComment, LineCommentEditor } from "./line-comment"
|
||||
import { LineComment, LineCommentEditor, type LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
export type LineCommentAnnotationMeta<T> =
|
||||
| { kind: "comment"; key: string; comment: T }
|
||||
@@ -55,6 +55,7 @@ type LineCommentControllerProps<T extends LineCommentShape> = {
|
||||
comments: Accessor<T[]>
|
||||
draftKey: Accessor<string>
|
||||
label: string
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
state: LineCommentStateProps<string>
|
||||
onSubmit: (input: { comment: string; selection: SelectedLineRange }) => void
|
||||
onUpdate?: (input: { id: string; comment: string; selection: SelectedLineRange }) => void
|
||||
@@ -85,6 +86,7 @@ type CommentProps = {
|
||||
type DraftProps = {
|
||||
value: string
|
||||
selection: JSX.Element
|
||||
mention?: LineCommentEditorProps["mention"]
|
||||
onInput: (value: string) => void
|
||||
onCancel: VoidFunction
|
||||
onSubmit: (value: string) => void
|
||||
@@ -148,6 +150,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
onPopoverFocusOut={view().editor!.onPopoverFocusOut}
|
||||
cancelLabel={view().editor!.cancelLabel}
|
||||
submitLabel={view().editor!.submitLabel}
|
||||
mention={view().editor!.mention}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
@@ -167,6 +170,7 @@ export function createLineCommentAnnotationRenderer<T>(props: {
|
||||
onCancel={view().onCancel}
|
||||
onSubmit={view().onSubmit}
|
||||
onPopoverFocusOut={view().onPopoverFocusOut}
|
||||
mention={view().mention}
|
||||
/>
|
||||
)
|
||||
}, host)
|
||||
@@ -389,6 +393,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(comment.selection, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (value: string) => {
|
||||
@@ -415,6 +420,7 @@ export function createLineCommentController<T extends LineCommentShape>(
|
||||
return note.draft()
|
||||
},
|
||||
selection: formatSelectedLineLabel(range, i18n.t),
|
||||
mention: props.mention,
|
||||
onInput: note.setDraft,
|
||||
onCancel: note.cancelDraft,
|
||||
onSubmit: (comment) => {
|
||||
|
||||
@@ -178,6 +178,58 @@ export const lineCommentStyles = `
|
||||
box-shadow: var(--shadow-xs-border-select);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface-base);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 6px 8px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-item"][data-active] {
|
||||
background: var(--surface-raised-base-hover);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-path"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
font-family: var(--font-family-sans);
|
||||
font-size: var(--font-size-small);
|
||||
line-height: var(--line-height-large);
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-dir"] {
|
||||
min-width: 0;
|
||||
color: var(--text-weak);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-mention-file"] {
|
||||
color: var(--text-strong);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
[data-component="line-comment"] [data-slot="line-comment-actions"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { createEffect, createSignal, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { useFilteredList } from "@opencode-ai/ui/hooks"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { createEffect, createSignal, For, onMount, Show, splitProps, type JSX } from "solid-js"
|
||||
import { Button } from "./button"
|
||||
import { FileIcon } from "./file-icon"
|
||||
import { Icon } from "./icon"
|
||||
import { installLineCommentStyles } from "./line-comment-styles"
|
||||
import { useI18n } from "../context/i18n"
|
||||
@@ -183,6 +186,9 @@ export type LineCommentEditorProps = Omit<LineCommentAnchorProps, "children" | "
|
||||
autofocus?: boolean
|
||||
cancelLabel?: string
|
||||
submitLabel?: string
|
||||
mention?: {
|
||||
items: (query: string) => string[] | Promise<string[]>
|
||||
}
|
||||
}
|
||||
|
||||
export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
@@ -198,12 +204,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
"autofocus",
|
||||
"cancelLabel",
|
||||
"submitLabel",
|
||||
"mention",
|
||||
])
|
||||
|
||||
const refs = {
|
||||
textarea: undefined as HTMLTextAreaElement | undefined,
|
||||
}
|
||||
const [text, setText] = createSignal(split.value)
|
||||
const [open, setOpen] = createSignal(false)
|
||||
|
||||
function selectMention(item: { path: string } | undefined) {
|
||||
if (!item) return
|
||||
|
||||
const textarea = refs.textarea
|
||||
const query = currentMention()
|
||||
if (!textarea || !query) return
|
||||
|
||||
const value = `${text().slice(0, query.start)}@${item.path} ${text().slice(query.end)}`
|
||||
const cursor = query.start + item.path.length + 2
|
||||
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
closeMention()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(cursor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
const mention = useFilteredList<{ path: string }>({
|
||||
items: async (query) => {
|
||||
if (!split.mention) return []
|
||||
if (!query.trim()) return []
|
||||
const paths = await split.mention.items(query)
|
||||
return paths.map((path) => ({ path }))
|
||||
},
|
||||
key: (item) => item.path,
|
||||
filterKeys: ["path"],
|
||||
onSelect: selectMention,
|
||||
})
|
||||
|
||||
const focus = () => refs.textarea?.focus()
|
||||
const hold: JSX.EventHandlerUnion<HTMLButtonElement, MouseEvent> = (e) => {
|
||||
@@ -221,6 +261,46 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
setText(split.value)
|
||||
})
|
||||
|
||||
const closeMention = () => {
|
||||
setOpen(false)
|
||||
mention.clear()
|
||||
}
|
||||
|
||||
const currentMention = () => {
|
||||
const textarea = refs.textarea
|
||||
if (!textarea) return
|
||||
if (!split.mention) return
|
||||
if (textarea.selectionStart !== textarea.selectionEnd) return
|
||||
|
||||
const end = textarea.selectionStart
|
||||
const match = textarea.value.slice(0, end).match(/@(\S*)$/)
|
||||
if (!match) return
|
||||
|
||||
return {
|
||||
query: match[1] ?? "",
|
||||
start: end - match[0].length,
|
||||
end,
|
||||
}
|
||||
}
|
||||
|
||||
const syncMention = () => {
|
||||
const item = currentMention()
|
||||
if (!item) {
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
mention.onInput(item.query)
|
||||
}
|
||||
|
||||
const selectActiveMention = () => {
|
||||
const items = mention.flat()
|
||||
if (items.length === 0) return
|
||||
const active = mention.active()
|
||||
selectMention(items.find((item) => item.path === active) ?? items[0])
|
||||
}
|
||||
|
||||
const submit = () => {
|
||||
const value = text().trim()
|
||||
if (!value) return
|
||||
@@ -247,11 +327,38 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
const value = (e.currentTarget as HTMLTextAreaElement).value
|
||||
setText(value)
|
||||
split.onInput(value)
|
||||
syncMention()
|
||||
}}
|
||||
on:click={() => syncMention()}
|
||||
on:select={() => syncMention()}
|
||||
on:keydown={(e) => {
|
||||
const event = e as KeyboardEvent
|
||||
if (event.isComposing || event.keyCode === 229) return
|
||||
event.stopPropagation()
|
||||
if (open()) {
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeMention()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === "Tab") {
|
||||
if (mention.flat().length === 0) return
|
||||
event.preventDefault()
|
||||
selectActiveMention()
|
||||
return
|
||||
}
|
||||
|
||||
const nav = e.key === "ArrowUp" || e.key === "ArrowDown" || e.key === "Enter"
|
||||
const ctrlNav =
|
||||
event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey && (e.key === "n" || e.key === "p")
|
||||
if ((nav || ctrlNav) && mention.flat().length > 0) {
|
||||
mention.onKeyDown(event)
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
event.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
@@ -264,6 +371,34 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => {
|
||||
submit()
|
||||
}}
|
||||
/>
|
||||
<Show when={open() && mention.flat().length > 0}>
|
||||
<div data-slot="line-comment-mention-list">
|
||||
<For each={mention.flat().slice(0, 10)}>
|
||||
{(item) => {
|
||||
const directory = item.path.endsWith("/") ? item.path : getDirectory(item.path)
|
||||
const name = item.path.endsWith("/") ? "" : getFilename(item.path)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="line-comment-mention-item"
|
||||
data-active={mention.active() === item.path ? "" : undefined}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => mention.setActive(item.path)}
|
||||
onClick={() => selectMention(item)}
|
||||
>
|
||||
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-4" />
|
||||
<div data-slot="line-comment-mention-path">
|
||||
<span data-slot="line-comment-mention-dir">{directory}</span>
|
||||
<Show when={name}>
|
||||
<span data-slot="line-comment-mention-file">{name}</span>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
<div data-slot="line-comment-actions">
|
||||
<div data-slot="line-comment-editor-label">
|
||||
{i18n.t("ui.lineComment.editorLabel.prefix")}
|
||||
|
||||
@@ -13,8 +13,7 @@ import { useFileComponent } from "../context/file"
|
||||
import { useI18n } from "../context/i18n"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { createEffect, createMemo, For, Match, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
|
||||
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
|
||||
@@ -23,8 +22,10 @@ import { Dynamic } from "solid-js/web"
|
||||
import { mediaKindFromPath } from "../pierre/media"
|
||||
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
|
||||
import { createLineCommentController } from "./line-comment-annotations"
|
||||
import type { LineCommentEditorProps } from "./line-comment"
|
||||
|
||||
const MAX_DIFF_CHANGED_LINES = 500
|
||||
const REVIEW_MOUNT_MARGIN = 300
|
||||
|
||||
export type SessionReviewDiffStyle = "unified" | "split"
|
||||
|
||||
@@ -68,7 +69,7 @@ export interface SessionReviewProps {
|
||||
split?: boolean
|
||||
diffStyle?: SessionReviewDiffStyle
|
||||
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
|
||||
onDiffRendered?: () => void
|
||||
onDiffRendered?: VoidFunction
|
||||
onLineComment?: (comment: SessionReviewLineComment) => void
|
||||
onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void
|
||||
onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void
|
||||
@@ -88,6 +89,7 @@ export interface SessionReviewProps {
|
||||
diffs: ReviewDiff[]
|
||||
onViewFile?: (file: string) => void
|
||||
readFile?: (path: string) => Promise<FileContent | undefined>
|
||||
lineCommentMention?: LineCommentEditorProps["mention"]
|
||||
}
|
||||
|
||||
function ReviewCommentMenu(props: {
|
||||
@@ -135,11 +137,14 @@ type SessionReviewSelection = {
|
||||
export const SessionReview = (props: SessionReviewProps) => {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let focusToken = 0
|
||||
let frame: number | undefined
|
||||
const i18n = useI18n()
|
||||
const fileComponent = useFileComponent()
|
||||
const anchors = new Map<string, HTMLElement>()
|
||||
const nodes = new Map<string, HTMLDivElement>()
|
||||
const [store, setStore] = createStore({
|
||||
open: [] as string[],
|
||||
visible: {} as Record<string, boolean>,
|
||||
force: {} as Record<string, boolean>,
|
||||
selection: null as SessionReviewSelection | null,
|
||||
commenting: null as SessionReviewSelection | null,
|
||||
@@ -152,13 +157,84 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const open = () => props.open ?? store.open
|
||||
const files = createMemo(() => props.diffs.map((diff) => diff.file))
|
||||
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
|
||||
const grouped = createMemo(() => {
|
||||
const next = new Map<string, SessionReviewComment[]>()
|
||||
for (const comment of props.comments ?? []) {
|
||||
const list = next.get(comment.file)
|
||||
if (list) {
|
||||
list.push(comment)
|
||||
continue
|
||||
}
|
||||
next.set(comment.file, [comment])
|
||||
}
|
||||
return next
|
||||
})
|
||||
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
|
||||
const hasDiffs = () => files().length > 0
|
||||
|
||||
const handleChange = (open: string[]) => {
|
||||
props.onOpenChange?.(open)
|
||||
if (props.open !== undefined) return
|
||||
setStore("open", open)
|
||||
const syncVisible = () => {
|
||||
frame = undefined
|
||||
if (!scroll) return
|
||||
|
||||
const root = scroll.getBoundingClientRect()
|
||||
const top = root.top - REVIEW_MOUNT_MARGIN
|
||||
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
|
||||
const openSet = new Set(open())
|
||||
const next: Record<string, boolean> = {}
|
||||
|
||||
for (const [file, el] of nodes) {
|
||||
if (!openSet.has(file)) continue
|
||||
const rect = el.getBoundingClientRect()
|
||||
if (rect.bottom < top || rect.top > bottom) continue
|
||||
next[file] = true
|
||||
}
|
||||
|
||||
const prev = untrack(() => store.visible)
|
||||
const prevKeys = Object.keys(prev)
|
||||
const nextKeys = Object.keys(next)
|
||||
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
|
||||
setStore("visible", next)
|
||||
}
|
||||
|
||||
const queue = () => {
|
||||
if (frame !== undefined) return
|
||||
frame = requestAnimationFrame(syncVisible)
|
||||
}
|
||||
|
||||
const pinned = (file: string) =>
|
||||
props.focusedComment?.file === file ||
|
||||
props.focusedFile === file ||
|
||||
selection()?.file === file ||
|
||||
commenting()?.file === file ||
|
||||
opened()?.file === file
|
||||
|
||||
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
|
||||
queue()
|
||||
const next = props.onScroll
|
||||
if (!next) return
|
||||
if (Array.isArray(next)) {
|
||||
const [fn, data] = next as [(data: unknown, event: Event) => void, unknown]
|
||||
fn(data, event)
|
||||
return
|
||||
}
|
||||
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (frame === undefined) return
|
||||
cancelAnimationFrame(frame)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
props.open
|
||||
files()
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleChange = (next: string[]) => {
|
||||
props.onOpenChange?.(next)
|
||||
if (props.open === undefined) setStore("open", next)
|
||||
queue()
|
||||
}
|
||||
|
||||
const handleExpandOrCollapseAll = () => {
|
||||
@@ -272,8 +348,9 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
viewportRef={(el) => {
|
||||
scroll = el
|
||||
props.scrollRef?.(el)
|
||||
queue()
|
||||
}}
|
||||
onScroll={props.onScroll as any}
|
||||
onScroll={handleScroll}
|
||||
classList={{
|
||||
[props.classes?.root ?? ""]: !!props.classes?.root,
|
||||
}}
|
||||
@@ -289,9 +366,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
const item = createMemo(() => diffs().get(file)!)
|
||||
|
||||
const expanded = createMemo(() => open().includes(file))
|
||||
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
|
||||
const force = () => !!store.force[file]
|
||||
|
||||
const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === file))
|
||||
const comments = createMemo(() => grouped().get(file) ?? [])
|
||||
const commentedLines = createMemo(() => comments().map((c) => c.selection))
|
||||
|
||||
const beforeText = () => (typeof item().before === "string" ? item().before : "")
|
||||
@@ -327,6 +405,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
comments,
|
||||
label: i18n.t("ui.lineComment.submit"),
|
||||
draftKey: () => file,
|
||||
mention: props.lineCommentMention,
|
||||
state: {
|
||||
opened: () => {
|
||||
const current = opened()
|
||||
@@ -378,6 +457,8 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
|
||||
onCleanup(() => {
|
||||
anchors.delete(file)
|
||||
nodes.delete(file)
|
||||
queue()
|
||||
})
|
||||
|
||||
const handleLineSelected = (range: SelectedLineRange | null) => {
|
||||
@@ -462,10 +543,19 @@ export const SessionReview = (props: SessionReviewProps) => {
|
||||
ref={(el) => {
|
||||
wrapper = el
|
||||
anchors.set(file, el)
|
||||
nodes.set(file, el)
|
||||
queue()
|
||||
}}
|
||||
>
|
||||
<Show when={expanded()}>
|
||||
<Switch>
|
||||
<Match when={!mounted() && !tooLarge()}>
|
||||
<div
|
||||
data-slot="session-review-diff-placeholder"
|
||||
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
|
||||
style={{ height: "160px" }}
|
||||
/>
|
||||
</Match>
|
||||
<Match when={tooLarge()}>
|
||||
<div data-slot="session-review-large-diff">
|
||||
<div data-slot="session-review-large-diff-title">
|
||||
|
||||
@@ -15,9 +15,17 @@ To create a new `AGENTS.md` file, you can run the `/init` command in opencode.
|
||||
You should commit your project's `AGENTS.md` file to Git.
|
||||
:::
|
||||
|
||||
This will scan your project and all its contents to understand what the project is about and generate an `AGENTS.md` file with it. This helps opencode to navigate the project better.
|
||||
`/init` scans the important files in your repo, may ask a couple of targeted questions when the codebase cannot answer them, and then creates or updates `AGENTS.md` with concise project-specific guidance.
|
||||
|
||||
If you have an existing `AGENTS.md` file, this will try to add to it.
|
||||
It focuses on the things future agent sessions are most likely to need:
|
||||
|
||||
- build, lint, and test commands
|
||||
- command order and focused verification steps when they matter
|
||||
- architecture and repo structure that are not obvious from filenames alone
|
||||
- project-specific conventions, setup quirks, and operational gotchas
|
||||
- references to existing instruction sources like Cursor or Copilot rules
|
||||
|
||||
If you already have an `AGENTS.md`, `/init` will improve it in place instead of blindly replacing it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ Show the help dialog.
|
||||
|
||||
### init
|
||||
|
||||
Create or update `AGENTS.md` file. [Learn more](/docs/rules).
|
||||
Guided setup for creating or updating `AGENTS.md`. [Learn more](/docs/rules).
|
||||
|
||||
```bash frame="none"
|
||||
/init
|
||||
|
||||
Reference in New Issue
Block a user