diff --git a/bun.lock b/bun.lock index 7ff8a3072f..eab55c5cf2 100644 --- a/bun.lock +++ b/bun.lock @@ -371,6 +371,7 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", + "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", @@ -2668,6 +2669,8 @@ "cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="], + "cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="], + "cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="], "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], @@ -3092,6 +3095,8 @@ "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="], + "find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="], + "find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], @@ -4412,6 +4417,8 @@ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], + "shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="], "shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f5cc0e0a9b..fcaac7b35f 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -128,6 +128,7 @@ "bonjour-service": "1.3.0", "bun-pty": "0.4.8", "chokidar": "4.0.3", + "cli-sound": "1.1.3", "clipboardy": "4.0.0", "cross-spawn": "catalog:", "decimal.js": "10.5.0", diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts new file mode 100644 index 0000000000..54a86efa30 --- /dev/null +++ b/packages/opencode/src/audio.d.ts @@ -0,0 +1,4 @@ +declare module "*.wav" { + const file: string + export default file +} diff --git a/packages/opencode/src/cli/cmd/tui/asset/charge.wav b/packages/opencode/src/cli/cmd/tui/asset/charge.wav new file mode 100644 index 0000000000..d9597899cd Binary files /dev/null and b/packages/opencode/src/cli/cmd/tui/asset/charge.wav differ diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav new file mode 100644 index 0000000000..2ebb6a38bc Binary files /dev/null and b/packages/opencode/src/cli/cmd/tui/asset/pulse-a.wav differ diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav new file mode 100644 index 0000000000..4e1b59c964 Binary files /dev/null and b/packages/opencode/src/cli/cmd/tui/asset/pulse-b.wav differ diff --git a/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav b/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav new file mode 100644 index 0000000000..feb56cacda Binary files /dev/null and b/packages/opencode/src/cli/cmd/tui/asset/pulse-c.wav differ diff --git a/packages/opencode/src/cli/cmd/tui/component/logo.tsx b/packages/opencode/src/cli/cmd/tui/component/logo.tsx index 8e6208b140..51cf69dc1f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/logo.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/logo.tsx @@ -1,82 +1,630 @@ -import { TextAttributes, RGBA } from "@opentui/core" -import { For, type JSX } from "solid-js" +import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@opentui/core" +import { For, createMemo, createSignal, onCleanup, type JSX } from "solid-js" import { useTheme, tint } from "@tui/context/theme" -import { logo, marks } from "@/cli/logo" +import { Sound } from "@tui/util/sound" +import { logo } from "@/cli/logo" // Shadow markers (rendered chars in parens): // _ = full shadow cell (space with bg=shadow) // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow) // ~ = shadow top only (▀ with fg=shadow) -const SHADOW_MARKER = new RegExp(`[${marks}]`) +const GAP = 1 +const WIDTH = 0.76 +const GAIN = 2.3 +const FLASH = 2.15 +const TRAIL = 0.28 +const SWELL = 0.24 +const WIDE = 1.85 +const DRIFT = 1.45 +const EXPAND = 1.62 +const LIFE = 1020 +const CHARGE = 3000 +const HOLD = 90 +const SINK = 40 +const ARC = 2.2 +const FORK = 1.2 +const DIM = 1.04 +const KICK = 0.86 +const LAG = 60 +const SUCK = 0.34 +const SHIMMER_IN = 60 +const SHIMMER_OUT = 2.8 +const TRACE = 0.033 +const TAIL = 1.8 +const TRACE_IN = 200 +const GLOW_OUT = 1600 +const PEAK = RGBA.fromInts(255, 255, 255) + +type Ring = { + x: number + y: number + at: number + force: number + kick: number +} + +type Hold = { + x: number + y: number + at: number + glyph: number | undefined +} + +type Release = { + x: number + y: number + at: number + glyph: number | undefined + level: number + rise: number +} + +type Glow = { + glyph: number + at: number + force: number +} + +type Frame = { + t: number + list: Ring[] + hold: Hold | undefined + release: Release | undefined + glow: Glow | undefined + spark: number +} + +const LEFT = logo.left[0]?.length ?? 0 +const FULL = logo.left.map((line, i) => line + " ".repeat(GAP) + logo.right[i]) +const SPAN = Math.hypot(FULL[0]?.length ?? 0, FULL.length * 2) * 0.94 +const NEAR = [ + [1, 0], + [1, 1], + [0, 1], + [-1, 1], + [-1, 0], + [-1, -1], + [0, -1], + [1, -1], +] as const + +type Trace = { + glyph: number + i: number + l: number +} + +function clamp(n: number) { + return Math.max(0, Math.min(1, n)) +} + +function lerp(a: number, b: number, t: number) { + return a + (b - a) * clamp(t) +} + +function ease(t: number) { + const p = clamp(t) + return p * p * (3 - 2 * p) +} + +function push(t: number) { + const p = clamp(t) + return ease(p * p) +} + +function ramp(t: number, start: number, end: number) { + if (end <= start) return ease(t >= end ? 1 : 0) + return ease((t - start) / (end - start)) +} + +function glow(base: RGBA, theme: ReturnType["theme"], n: number) { + const mid = tint(base, theme.primary, 0.84) + const top = tint(theme.primary, PEAK, 0.96) + if (n <= 1) return tint(base, mid, Math.min(1, Math.sqrt(Math.max(0, n)) * 1.14)) + return tint(mid, top, Math.min(1, 1 - Math.exp(-2.4 * (n - 1)))) +} + +function shade(base: RGBA, theme: ReturnType["theme"], n: number) { + if (n >= 0) return glow(base, theme, n) + return tint(base, theme.background, Math.min(0.82, -n * 0.64)) +} + +function ghost(n: number, scale: number) { + if (n < 0) return n + return n * scale +} + +function noise(x: number, y: number, t: number) { + const n = Math.sin(x * 12.9898 + y * 78.233 + t * 0.043) * 43758.5453 + return n - Math.floor(n) +} + +function lit(char: string) { + return char !== " " && char !== "_" && char !== "~" +} + +function key(x: number, y: number) { + return `${x},${y}` +} + +function route(list: Array<{ x: number; y: number }>) { + const left = new Map(list.map((item) => [key(item.x, item.y), item])) + const path: Array<{ x: number; y: number }> = [] + let cur = [...left.values()].sort((a, b) => a.y - b.y || a.x - b.x)[0] + let dir = { x: 1, y: 0 } + + while (cur) { + path.push(cur) + left.delete(key(cur.x, cur.y)) + if (!left.size) return path + + const next = NEAR.map(([dx, dy]) => left.get(key(cur.x + dx, cur.y + dy))) + .filter((item): item is { x: number; y: number } => !!item) + .sort((a, b) => { + const ax = a.x - cur.x + const ay = a.y - cur.y + const bx = b.x - cur.x + const by = b.y - cur.y + const adot = ax * dir.x + ay * dir.y + const bdot = bx * dir.x + by * dir.y + if (adot !== bdot) return bdot - adot + return Math.abs(ax) + Math.abs(ay) - (Math.abs(bx) + Math.abs(by)) + })[0] + + if (!next) { + cur = [...left.values()].sort((a, b) => { + const da = (a.x - cur.x) ** 2 + (a.y - cur.y) ** 2 + const db = (b.x - cur.x) ** 2 + (b.y - cur.y) ** 2 + return da - db + })[0] + dir = { x: 1, y: 0 } + continue + } + + dir = { x: next.x - cur.x, y: next.y - cur.y } + cur = next + } + + return path +} + +function mapGlyphs() { + const cells = [] as Array<{ x: number; y: number }> + + for (let y = 0; y < FULL.length; y++) { + for (let x = 0; x < (FULL[y]?.length ?? 0); x++) { + if (lit(FULL[y]?.[x] ?? " ")) cells.push({ x, y }) + } + } + + const all = new Map(cells.map((item) => [key(item.x, item.y), item])) + const seen = new Set() + const glyph = new Map() + const trace = new Map() + const center = new Map() + let id = 0 + + for (const item of cells) { + const start = key(item.x, item.y) + if (seen.has(start)) continue + const stack = [item] + const part = [] as Array<{ x: number; y: number }> + seen.add(start) + + while (stack.length) { + const cur = stack.pop()! + part.push(cur) + glyph.set(key(cur.x, cur.y), id) + for (const [dx, dy] of NEAR) { + const next = all.get(key(cur.x + dx, cur.y + dy)) + if (!next) continue + const mark = key(next.x, next.y) + if (seen.has(mark)) continue + seen.add(mark) + stack.push(next) + } + } + + const path = route(part) + path.forEach((cell, i) => trace.set(key(cell.x, cell.y), { glyph: id, i, l: path.length })) + center.set(id, { + x: part.reduce((sum, item) => sum + item.x, 0) / part.length + 0.5, + y: (part.reduce((sum, item) => sum + item.y, 0) / part.length) * 2 + 1, + }) + id++ + } + + return { glyph, trace, center } +} + +const MAP = mapGlyphs() + +function shimmer(x: number, y: number, frame: Frame) { + return frame.list.reduce((best, item) => { + const age = frame.t - item.at + if (age < SHIMMER_IN || age > LIFE) return best + const dx = x + 0.5 - item.x + const dy = y * 2 + 1 - item.y + const dist = Math.hypot(dx, dy) + const p = age / LIFE + const r = SPAN * (1 - (1 - p) ** EXPAND) + const lag = r - dist + if (lag < 0.18 || lag > SHIMMER_OUT) return best + const band = Math.exp(-(((lag - 1.05) / 0.68) ** 2)) + const wobble = 0.5 + 0.5 * Math.sin(frame.t * 0.035 + x * 0.9 + y * 1.7) + const n = band * wobble * (1 - p) ** 1.45 + if (n > best) return n + return best + }, 0) +} + +function remain(x: number, y: number, item: Release, t: number) { + const age = t - item.at + if (age < 0 || age > LIFE) return 0 + const p = age / LIFE + const dx = x + 0.5 - item.x - 0.5 + const dy = y * 2 + 1 - item.y * 2 - 1 + const dist = Math.hypot(dx, dy) + const r = SPAN * (1 - (1 - p) ** EXPAND) + if (dist > r) return 1 + return clamp((r - dist) / 1.35 < 1 ? 1 - (r - dist) / 1.35 : 0) +} + +function wave(x: number, y: number, frame: Frame, live: boolean) { + return frame.list.reduce((sum, item) => { + const age = frame.t - item.at + if (age < 0 || age > LIFE) return sum + const p = age / LIFE + const dx = x + 0.5 - item.x + const dy = y * 2 + 1 - item.y + const dist = Math.hypot(dx, dy) + const r = SPAN * (1 - (1 - p) ** EXPAND) + const fade = (1 - p) ** 1.32 + const j = 1.02 + noise(x + item.x * 0.7, y + item.y * 0.7, item.at * 0.002 + age * 0.06) * 0.52 + const edge = Math.exp(-(((dist - r) / WIDTH) ** 2)) * GAIN * fade * item.force * j + const swell = Math.exp(-(((dist - Math.max(0, r - DRIFT)) / WIDE) ** 2)) * SWELL * fade * item.force + const trail = dist < r ? Math.exp(-(r - dist) / 2.4) * TRAIL * fade * item.force * lerp(0.92, 1.22, j) : 0 + const flash = Math.exp(-(dist * dist) / 3.2) * FLASH * item.force * Math.max(0, 1 - age / 140) * lerp(0.95, 1.18, j) + const kick = Math.exp(-(dist * dist) / 2) * item.kick * Math.max(0, 1 - age / 100) + const suck = Math.exp(-(((dist - 1.25) / 0.75) ** 2)) * item.kick * SUCK * Math.max(0, 1 - age / 110) + const wake = live && dist < r ? Math.exp(-(r - dist) / 1.25) * 0.32 * fade : 0 + return sum + edge + swell + trail + flash + wake - kick - suck + }, 0) +} + +function field(x: number, y: number, frame: Frame) { + const held = frame.hold + const rest = frame.release + const item = held ?? rest + if (!item) return 0 + const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise + const level = held ? push(rise) : rest!.level + const body = rise + const storm = level * level + const sink = held ? ramp(frame.t - held.at, SINK, CHARGE) : rest!.rise + const dx = x + 0.5 - item.x - 0.5 + const dy = y * 2 + 1 - item.y * 2 - 1 + const dist = Math.hypot(dx, dy) + const angle = Math.atan2(dy, dx) + const spin = frame.t * lerp(0.008, 0.018, storm) + const dim = lerp(0, DIM, sink) * lerp(0.99, 1.01, 0.5 + 0.5 * Math.sin(frame.t * 0.014)) + const core = Math.exp(-(dist * dist) / Math.max(0.22, lerp(0.22, 3.2, body))) * lerp(0.42, 2.45, body) + const shell = + Math.exp(-(((dist - lerp(0.16, 2.05, body)) / Math.max(0.18, lerp(0.18, 0.82, body))) ** 2)) * lerp(0.1, 0.95, body) + const ember = + Math.exp(-(((dist - lerp(0.45, 2.65, body)) / Math.max(0.14, lerp(0.14, 0.62, body))) ** 2)) * + lerp(0.02, 0.78, body) + const arc = Math.max(0, Math.cos(angle * 3 - spin + frame.spark * 2.2)) ** 8 + const seam = Math.max(0, Math.cos(angle * 5 + spin * 1.55)) ** 12 + const ring = Math.exp(-(((dist - lerp(1.05, 3, level)) / 0.48) ** 2)) * arc * lerp(0.03, 0.5 + ARC, storm) + const fork = Math.exp(-(((dist - (1.55 + storm * 2.1)) / 0.36) ** 2)) * seam * storm * FORK + const spark = Math.max(0, noise(x, y, frame.t) - lerp(0.94, 0.66, storm)) * lerp(0, 5.4, storm) + const glitch = spark * Math.exp(-dist / Math.max(1.2, 3.1 - storm)) + const crack = Math.max(0, Math.cos((dx - dy) * 1.6 + spin * 2.1)) ** 18 + const lash = crack * Math.exp(-(((dist - (1.95 + storm * 2)) / 0.28) ** 2)) * storm * 1.1 + const flicker = + Math.max(0, noise(item.x * 3.1, item.y * 2.7, frame.t * 1.7) - 0.72) * + Math.exp(-(dist * dist) / 0.15) * + lerp(0.08, 0.42, body) + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + return (core + shell + ember + ring + fork + glitch + lash + flicker - dim) * fade +} + +function pick(x: number, y: number, frame: Frame) { + const held = frame.hold + const rest = frame.release + const item = held ?? rest + if (!item) return 0 + const rise = held ? ramp(frame.t - held.at, HOLD, CHARGE) : rest!.rise + const dx = x + 0.5 - item.x - 0.5 + const dy = y * 2 + 1 - item.y * 2 - 1 + const dist = Math.hypot(dx, dy) + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + return Math.exp(-(dist * dist) / 1.7) * lerp(0.2, 0.96, rise) * fade +} + +function select(x: number, y: number) { + const direct = MAP.glyph.get(key(x, y)) + if (direct !== undefined) return direct + + const near = NEAR.map(([dx, dy]) => MAP.glyph.get(key(x + dx, y + dy))).find( + (item): item is number => item !== undefined, + ) + return near +} + +function trace(x: number, y: number, frame: Frame) { + const held = frame.hold + const rest = frame.release + const item = held ?? rest + if (!item || item.glyph === undefined) return 0 + const step = MAP.trace.get(key(x, y)) + if (!step || step.glyph !== item.glyph || step.l < 2) return 0 + const age = frame.t - item.at + const rise = held ? ramp(age, HOLD, CHARGE) : rest!.rise + const appear = held ? ramp(age, 0, TRACE_IN) : 1 + const speed = lerp(TRACE * 0.48, TRACE * 0.88, rise) + const head = (age * speed) % step.l + const dist = Math.min(Math.abs(step.i - head), step.l - Math.abs(step.i - head)) + const tail = (head - TAIL + step.l) % step.l + const lag = Math.min(Math.abs(step.i - tail), step.l - Math.abs(step.i - tail)) + const fade = frame.release && !frame.hold ? remain(x, y, frame.release, frame.t) : 1 + const core = Math.exp(-((dist / 1.05) ** 2)) * lerp(0.8, 2.35, rise) + const glow = Math.exp(-((dist / 1.85) ** 2)) * lerp(0.08, 0.34, rise) + const trail = Math.exp(-((lag / 1.45) ** 2)) * lerp(0.04, 0.42, rise) + return (core + glow + trail) * appear * fade +} + +function bloom(x: number, y: number, frame: Frame) { + const item = frame.glow + if (!item) return 0 + const glyph = MAP.glyph.get(key(x, y)) + if (glyph !== item.glyph) return 0 + const age = frame.t - item.at + if (age < 0 || age > GLOW_OUT) return 0 + const p = age / GLOW_OUT + const flash = (1 - p) ** 2 + const dx = x + 0.5 - MAP.center.get(item.glyph)!.x + const dy = y * 2 + 1 - MAP.center.get(item.glyph)!.y + const bias = Math.exp(-((Math.hypot(dx, dy) / 2.8) ** 2)) + return lerp(item.force, item.force * 0.18, p) * lerp(0.72, 1.1, bias) * flash +} export function Logo() { const { theme } = useTheme() + const [rings, setRings] = createSignal([]) + const [hold, setHold] = createSignal() + const [release, setRelease] = createSignal() + const [glow, setGlow] = createSignal() + const [now, setNow] = createSignal(0) + let box: BoxRenderable | undefined + let timer: ReturnType | undefined + let hum = false - const renderLine = (line: string, fg: RGBA, bold: boolean): JSX.Element[] => { - const shadow = tint(theme.background, fg, 0.25) + const stop = () => { + if (!timer) return + clearInterval(timer) + timer = undefined + } + + const tick = () => { + const t = performance.now() + setNow(t) + const item = hold() + if (item && !hum && t - item.at >= HOLD) { + hum = true + Sound.start() + } + if (item && t - item.at >= CHARGE) { + burst(item.x, item.y) + } + let live = false + setRings((list) => { + const next = list.filter((item) => t - item.at < LIFE) + live = next.length > 0 + return next + }) + const flash = glow() + if (flash && t - flash.at >= GLOW_OUT) { + setGlow(undefined) + } + if (!live) setRelease(undefined) + if (live || hold() || release() || glow()) return + stop() + } + + const start = () => { + if (timer) return + timer = setInterval(tick, 16) + } + + const hit = (x: number, y: number) => { + const char = FULL[y]?.[x] + return char !== undefined && char !== " " + } + + const press = (x: number, y: number, t: number) => { + const last = hold() + if (last) burst(last.x, last.y) + setNow(t) + if (!last) setRelease(undefined) + setHold({ x, y, at: t, glyph: select(x, y) }) + hum = false + start() + } + + const burst = (x: number, y: number) => { + const item = hold() + if (!item) return + hum = false + const t = performance.now() + const age = t - item.at + const rise = ramp(age, HOLD, CHARGE) + const level = push(rise) + setHold(undefined) + setRelease({ x, y, at: t, glyph: item.glyph, level, rise }) + if (item.glyph !== undefined) { + setGlow({ glyph: item.glyph, at: t, force: lerp(0.18, 1.5, rise * level) }) + } + setRings((list) => [ + ...list, + { + x: x + 0.5, + y: y * 2 + 1, + at: t, + force: lerp(0.82, 2.55, level), + kick: lerp(0.32, 0.32 + KICK, level), + }, + ]) + setNow(t) + start() + Sound.pulse(lerp(0.8, 1, level)) + } + + const frame = createMemo(() => { + const t = now() + const item = hold() + return { + t, + list: rings(), + hold: item, + release: release(), + glow: glow(), + spark: item ? noise(item.x, item.y, t) : 0, + } + }) + + const dusk = createMemo(() => { + const base = frame() + const t = base.t - LAG + const item = base.hold + return { + t, + list: base.list, + hold: item, + release: base.release, + glow: base.glow, + spark: item ? noise(item.x, item.y, t) : 0, + } + }) + + const renderLine = ( + line: string, + y: number, + ink: RGBA, + bold: boolean, + off: number, + frame: Frame, + dusk: Frame, + ): JSX.Element[] => { + const shadow = tint(theme.background, ink, 0.25) const attrs = bold ? TextAttributes.BOLD : undefined - const elements: JSX.Element[] = [] - let i = 0 - while (i < line.length) { - const rest = line.slice(i) - const markerIndex = rest.search(SHADOW_MARKER) + return [...line].map((char, i) => { + const h = field(off + i, y, frame) + const n = wave(off + i, y, frame, lit(char)) + h + const s = wave(off + i, y, dusk, false) + h + const p = lit(char) ? pick(off + i, y, frame) : 0 + const e = lit(char) ? trace(off + i, y, frame) : 0 + const b = lit(char) ? bloom(off + i, y, frame) : 0 + const q = shimmer(off + i, y, frame) - if (markerIndex === -1) { - elements.push( - - {rest} - , - ) - break - } - - if (markerIndex > 0) { - elements.push( - - {rest.slice(0, markerIndex)} - , + if (char === "_") { + return ( + + {" "} + ) } - const marker = rest[markerIndex] - switch (marker) { - case "_": - elements.push( - - {" "} - , - ) - break - case "^": - elements.push( - - ▀ - , - ) - break - case "~": - elements.push( - - ▀ - , - ) - break + if (char === "^") { + return ( + + ▀ + + ) } - i += markerIndex + 1 + if (char === "~") { + return ( + + ▀ + + ) + } + + if (char === " ") { + return ( + + {char} + + ) + } + + return ( + + {char} + + ) + }) + } + + onCleanup(() => { + stop() + hum = false + Sound.dispose() + }) + + const mouse = (evt: MouseEvent) => { + if (!box) return + if ((evt.type === "down" || evt.type === "drag") && evt.button === MouseButton.LEFT) { + const x = evt.x - box.x + const y = evt.y - box.y + if (!hit(x, y)) return + if (evt.type === "drag" && hold()) return + evt.preventDefault() + evt.stopPropagation() + const t = performance.now() + press(x, y, t) + return } - return elements + if (!hold()) return + if (evt.type === "up") { + const item = hold() + if (!item) return + burst(item.x, item.y) + } } return ( - + (box = item)}> + {(line, index) => ( - {renderLine(line, theme.textMuted, false)} - {renderLine(logo.right[index()], theme.text, true)} + {renderLine(line, index(), theme.textMuted, false, 0, frame(), dusk())} + + {renderLine(logo.right[index()], index(), theme.text, true, LEFT + GAP, frame(), dusk())} + )} diff --git a/packages/opencode/src/cli/cmd/tui/util/sound.ts b/packages/opencode/src/cli/cmd/tui/util/sound.ts new file mode 100644 index 0000000000..d3a8db8b4f --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/sound.ts @@ -0,0 +1,156 @@ +import { Player } from "cli-sound" +import { mkdirSync } from "node:fs" +import { tmpdir } from "node:os" +import { basename, join } from "node:path" +import { Process } from "@/util/process" +import { which } from "@/util/which" +import pulseA from "../asset/pulse-a.wav" with { type: "file" } +import pulseB from "../asset/pulse-b.wav" with { type: "file" } +import pulseC from "../asset/pulse-c.wav" with { type: "file" } +import charge from "../asset/charge.wav" with { type: "file" } + +const FILE = [pulseA, pulseB, pulseC] + +const HUM = charge +const DIR = join(tmpdir(), "opencode-sfx") + +const LIST = [ + "ffplay", + "mpv", + "mpg123", + "mpg321", + "mplayer", + "afplay", + "play", + "omxplayer", + "aplay", + "cmdmp3", + "cvlc", + "powershell.exe", +] as const + +type Kind = (typeof LIST)[number] + +function args(kind: Kind, file: string, volume: number) { + if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file] + if (kind === "mpv") + return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file] + if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file] + if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file] + if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file] + if (kind === "play") return [kind, "-v", String(volume), file] + if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file] + return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`] +} + +export namespace Sound { + let item: Player | null | undefined + let kind: Kind | null | undefined + let proc: Process.Child | undefined + let tail: ReturnType | undefined + let cache: Promise<{ hum: string; pulse: string[] }> | undefined + let seq = 0 + let shot = 0 + + function load() { + if (item !== undefined) return item + try { + item = new Player({ volume: 0.35 }) + } catch { + item = null + } + return item + } + + async function file(path: string) { + mkdirSync(DIR, { recursive: true }) + const next = join(DIR, basename(path)) + const out = Bun.file(next) + if (await out.exists()) return next + await Bun.write(out, Bun.file(path)) + return next + } + + function asset() { + cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse })) + return cache + } + + function pick() { + if (kind !== undefined) return kind + kind = LIST.find((item) => which(item)) ?? null + return kind + } + + function run(file: string, volume: number) { + const kind = pick() + if (!kind) return + return Process.spawn(args(kind, file, volume), { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }) + } + + function clear() { + if (!tail) return + clearTimeout(tail) + tail = undefined + } + + function play(file: string, volume: number) { + const item = load() + if (!item) return run(file, volume)?.exited + return item.play(file, { volume }).catch(() => run(file, volume)?.exited) + } + + export function start() { + stop() + const id = ++seq + void asset().then(({ hum }) => { + if (id !== seq) return + const next = run(hum, 0.24) + if (!next) return + proc = next + void next.exited.then( + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + () => { + if (id !== seq) return + if (proc === next) proc = undefined + }, + ) + }) + } + + export function stop(delay = 0) { + seq++ + clear() + if (!proc) return + const next = proc + if (delay <= 0) { + proc = undefined + void Process.stop(next).catch(() => undefined) + return + } + tail = setTimeout(() => { + tail = undefined + if (proc === next) proc = undefined + void Process.stop(next).catch(() => undefined) + }, delay) + } + + export function pulse(scale = 1) { + stop(140) + const index = shot++ % FILE.length + void asset() + .then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale)) + .catch(() => undefined) + } + + export function dispose() { + stop() + } +} diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index 81cd2bf0dc..c77fbe3210 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -330,6 +330,7 @@ export namespace Ripgrep { glob?: string[] limit?: number follow?: boolean + file?: string[] }) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error> } @@ -351,6 +352,7 @@ export namespace Ripgrep { maxDepth?: number limit?: number pattern?: string + file?: string[] }) { const out = [yield* bin(), input.mode === "search" ? "--json" : "--files", "--glob=!.git/*"] if (input.follow) out.push("--follow") @@ -363,7 +365,7 @@ export namespace Ripgrep { } if (input.limit) out.push(`--max-count=${input.limit}`) if (input.mode === "search") out.push("--no-messages") - if (input.pattern) out.push("--", input.pattern) + if (input.pattern) out.push("--", input.pattern, ...(input.file ?? [])) return out }) @@ -405,6 +407,7 @@ export namespace Ripgrep { glob?: string[] limit?: number follow?: boolean + file?: string[] }) { return yield* Effect.scoped( Effect.gen(function* () { @@ -414,6 +417,7 @@ export namespace Ripgrep { follow: input.follow, limit: input.limit, pattern: input.pattern, + file: input.file, }) const handle = yield* spawner.spawn( diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index a45aaf59d5..dc22d32b4b 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -2,7 +2,6 @@ import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" import { Config } from "@/config/config" import { InstanceState } from "@/effect/instance-state" -import { makeRuntime } from "@/effect/run-service" import { ProjectID } from "@/project/schema" import { Instance } from "@/project/instance" import { MessageID, SessionID } from "@/session/schema" @@ -308,18 +307,4 @@ export namespace Permission { } export const defaultLayer = layer.pipe(Layer.provide(Bus.layer)) - - export const { runPromise } = makeRuntime(Service, defaultLayer) - - export async function ask(input: z.infer) { - return runPromise((s) => s.ask(input)) - } - - export async function reply(input: z.infer) { - return runPromise((s) => s.reply(input)) - } - - export async function list() { - return runPromise((s) => s.list()) - } } diff --git a/packages/opencode/src/server/instance/permission.ts b/packages/opencode/src/server/instance/permission.ts index aae9a9c3a6..3f93709354 100644 --- a/packages/opencode/src/server/instance/permission.ts +++ b/packages/opencode/src/server/instance/permission.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import { AppRuntime } from "@/effect/app-runtime" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { errors } from "../error" @@ -36,11 +37,15 @@ export const PermissionRoutes = lazy(() => async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") - await Permission.reply({ - requestID: params.requestID, - reply: json.reply, - message: json.message, - }) + await AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.reply({ + requestID: params.requestID, + reply: json.reply, + message: json.message, + }), + ), + ) return c.json(true) }, ) @@ -62,7 +67,7 @@ export const PermissionRoutes = lazy(() => }, }), async (c) => { - const permissions = await Permission.list() + const permissions = await AppRuntime.runPromise(Permission.Service.use((svc) => svc.list())) return c.json(permissions) }, ), diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index 32bd3d9fc8..86d6a8ef42 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -1070,10 +1070,14 @@ export const SessionRoutes = lazy(() => validator("json", z.object({ response: Permission.Reply })), async (c) => { const params = c.req.valid("param") - Permission.reply({ - requestID: params.permissionID, - reply: c.req.valid("json").response, - }) + await AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.reply({ + requestID: params.permissionID, + reply: c.req.valid("json").response, + }), + ), + ) return c.json(true) }, ), diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index c3607e1770..3ab35958a4 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,7 +1,6 @@ import { Provider } from "@/provider/provider" import { Log } from "@/util/log" -import { Cause, Effect, Layer, Record, Context } from "effect" -import * as Queue from "effect/Queue" +import { Context, Effect, Layer, Record } from "effect" import * as Stream from "effect/Stream" import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" import { mergeDeep, pipe } from "remeda" @@ -21,10 +20,13 @@ import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" import { Installation } from "@/installation" +import { makeRuntime } from "@/effect/run-service" export namespace LLM { const log = Log.create({ service: "llm" }) + const perms = makeRuntime(Permission.Service, Permission.defaultLayer) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX + type Result = Awaited> export type StreamInput = { user: MessageV2.User @@ -45,7 +47,7 @@ export namespace LLM { abort: AbortSignal } - export type Event = Awaited>["fullStream"] extends AsyncIterable ? T : never + export type Event = Result["fullStream"] extends AsyncIterable ? T : never export interface Interface { readonly stream: (input: StreamInput) => Stream.Stream @@ -53,12 +55,340 @@ export namespace LLM { export class Service extends Context.Service()("@opencode/LLM") {} - export const layer = Layer.effect( - Service, - Effect.gen(function* () { - return Service.of({ - stream(input) { - return Stream.scoped( + export const layer: Layer.Layer = + Layer.effect( + Service, + Effect.gen(function* () { + const auth = yield* Auth.Service + const config = yield* Config.Service + const provider = yield* Provider.Service + const plugin = yield* Plugin.Service + + const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { + const l = log + .clone() + .tag("providerID", input.model.providerID) + .tag("modelID", input.model.id) + .tag("sessionID", input.sessionID) + .tag("small", (input.small ?? false).toString()) + .tag("agent", input.agent.name) + .tag("mode", input.agent.mode) + l.info("stream", { + modelID: input.model.id, + providerID: input.model.providerID, + }) + + const [language, cfg, item, info] = yield* Effect.all( + [ + provider.getLanguage(input.model), + config.get(), + provider.getProvider(input.model.providerID), + auth.get(input.model.providerID), + ], + { concurrency: "unbounded" }, + ) + + // TODO: move this to a proper hook + const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" + + const system: string[] = [] + system.push( + [ + // use agent prompt otherwise provider prompt + ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), + // any custom prompt passed into this call + ...input.system, + // any custom prompt from last user message + ...(input.user.system ? [input.user.system] : []), + ] + .filter((x) => x) + .join("\n"), + ) + + const header = system[0] + yield* plugin.trigger( + "experimental.chat.system.transform", + { sessionID: input.sessionID, model: input.model }, + { system }, + ) + // rejoin to maintain 2-part structure for caching if header unchanged + if (system.length > 2 && system[0] === header) { + const rest = system.slice(1) + system.length = 0 + system.push(header, rest.join("\n")) + } + + const variant = + !input.small && input.model.variants && input.user.model.variant + ? input.model.variants[input.user.model.variant] + : {} + const base = input.small + ? ProviderTransform.smallOptions(input.model) + : ProviderTransform.options({ + model: input.model, + sessionID: input.sessionID, + providerOptions: item.options, + }) + const options: Record = pipe( + base, + mergeDeep(input.model.options), + mergeDeep(input.agent.options), + mergeDeep(variant), + ) + if (isOpenaiOauth) { + options.instructions = system.join("\n") + } + + const isWorkflow = language instanceof GitLabWorkflowLanguageModel + const messages = isOpenaiOauth + ? input.messages + : isWorkflow + ? input.messages + : [ + ...system.map( + (x): ModelMessage => ({ + role: "system", + content: x, + }), + ), + ...input.messages, + ] + + const params = yield* plugin.trigger( + "chat.params", + { + sessionID: input.sessionID, + agent: input.agent.name, + model: input.model, + provider: item, + message: input.user, + }, + { + temperature: input.model.capabilities.temperature + ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) + : undefined, + topP: input.agent.topP ?? ProviderTransform.topP(input.model), + topK: ProviderTransform.topK(input.model), + maxOutputTokens: ProviderTransform.maxOutputTokens(input.model), + options, + }, + ) + + const { headers } = yield* plugin.trigger( + "chat.headers", + { + sessionID: input.sessionID, + agent: input.agent.name, + model: input.model, + provider: item, + message: input.user, + }, + { + headers: {}, + }, + ) + + const tools = resolveTools(input) + + // LiteLLM and some Anthropic proxies require the tools parameter to be present + // when message history contains tool calls, even if no tools are being used. + // Add a dummy tool that is never called to satisfy this validation. + // This is enabled for: + // 1. Providers with "litellm" in their ID or API ID (auto-detected) + // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways) + const isLiteLLMProxy = + item.options?.["litellmProxy"] === true || + input.model.providerID.toLowerCase().includes("litellm") || + input.model.api.id.toLowerCase().includes("litellm") + + // LiteLLM/Bedrock rejects requests where the message history contains tool + // calls but no tools param is present. When there are no active tools (e.g. + // during compaction), inject a stub tool to satisfy the validation requirement. + // The stub description explicitly tells the model not to call it. + if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) { + tools["_noop"] = tool({ + description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", + inputSchema: jsonSchema({ + type: "object", + properties: { + reason: { type: "string", description: "Unused" }, + }, + }), + execute: async () => ({ output: "", title: "", metadata: {} }), + }) + } + + // Wire up toolExecutor for DWS workflow models so that tool calls + // from the workflow service are executed via opencode's tool system + // and results sent back over the WebSocket. + if (language instanceof GitLabWorkflowLanguageModel) { + const workflowModel = language as GitLabWorkflowLanguageModel & { + sessionID?: string + sessionPreapprovedTools?: string[] + approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }> + } + workflowModel.sessionID = input.sessionID + workflowModel.systemPrompt = system.join("\n") + workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { + const t = tools[toolName] + if (!t || !t.execute) { + return { result: "", error: `Unknown tool: ${toolName}` } + } + try { + const result = await t.execute!(JSON.parse(argsJson), { + toolCallId: _requestID, + messages: input.messages, + abortSignal: input.abort, + }) + const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result)) + return { + result: output, + metadata: typeof result === "object" ? result?.metadata : undefined, + title: typeof result === "object" ? result?.title : undefined, + } + } catch (e: any) { + return { result: "", error: e.message ?? String(e) } + } + } + + const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) + workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => { + const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) + return !match || match.action !== "ask" + }) + + const approvedToolsForSession = new Set() + workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { + const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] + // Auto-approve tools that were already approved in this session + // (prevents infinite approval loops for server-side MCP tools) + if (uniqueNames.every((name) => approvedToolsForSession.has(name))) { + return { approved: true } + } + + const id = PermissionID.ascending() + let reply: Permission.Reply | undefined + let unsub: (() => void) | undefined + try { + unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { + if (evt.properties.requestID === id) reply = evt.properties.reply + }) + const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { + try { + const parsed = JSON.parse(t.args) as Record + const title = (parsed?.title ?? parsed?.name ?? "") as string + return title ? `${t.name}: ${title}` : t.name + } catch { + return t.name + } + }) + const uniquePatterns = [...new Set(toolPatterns)] as string[] + await perms.runPromise((svc) => + svc.ask({ + id, + sessionID: SessionID.make(input.sessionID), + permission: "workflow_tool_approval", + patterns: uniquePatterns, + metadata: { tools: approvalTools }, + always: uniquePatterns, + ruleset: [], + }), + ) + for (const name of uniqueNames) approvedToolsForSession.add(name) + workflowModel.sessionPreapprovedTools = [ + ...(workflowModel.sessionPreapprovedTools ?? []), + ...uniqueNames, + ] + return { approved: true } + } catch { + return { approved: false } + } finally { + unsub?.() + } + }) + } + + return streamText({ + onError(error) { + l.error("stream error", { + error, + }) + }, + async experimental_repairToolCall(failed) { + const lower = failed.toolCall.toolName.toLowerCase() + if (lower !== failed.toolCall.toolName && tools[lower]) { + l.info("repairing tool call", { + tool: failed.toolCall.toolName, + repaired: lower, + }) + return { + ...failed.toolCall, + toolName: lower, + } + } + return { + ...failed.toolCall, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, + }), + toolName: "invalid", + } + }, + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + providerOptions: ProviderTransform.providerOptions(input.model, params.options), + activeTools: Object.keys(tools).filter((x) => x !== "invalid"), + tools, + toolChoice: input.toolChoice, + maxOutputTokens: params.maxOutputTokens, + abortSignal: input.abort, + headers: { + ...(input.model.providerID.startsWith("opencode") + ? { + "x-opencode-project": Instance.project.id, + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": Flag.OPENCODE_CLIENT, + } + : { + "x-session-affinity": input.sessionID, + ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), + "User-Agent": `opencode/${Installation.VERSION}`, + }), + ...input.model.headers, + ...headers, + }, + maxRetries: input.retries ?? 0, + messages, + model: wrapLanguageModel({ + model: language, + middleware: [ + { + specificationVersion: "v3" as const, + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + } + return args.params + }, + }, + ], + }), + experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, + metadata: { + userId: cfg.username ?? "unknown", + sessionId: input.sessionID, + }, + }, + }) + }) + + const stream: Interface["stream"] = (input) => + Stream.scoped( Stream.unwrap( Effect.gen(function* () { const ctrl = yield* Effect.acquireRelease( @@ -66,7 +396,7 @@ export namespace LLM { (ctrl) => Effect.sync(() => ctrl.abort()), ) - const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal })) + const result = yield* run({ ...input, abort: ctrl.signal }) return Stream.fromAsyncIterable(result.fullStream, (e) => e instanceof Error ? e : new Error(String(e)), @@ -74,335 +404,19 @@ export namespace LLM { }), ), ) - }, - }) - }), - ) - export const defaultLayer = layer - - export async function stream(input: StreamRequest) { - const l = log - .clone() - .tag("providerID", input.model.providerID) - .tag("modelID", input.model.id) - .tag("sessionID", input.sessionID) - .tag("small", (input.small ?? false).toString()) - .tag("agent", input.agent.name) - .tag("mode", input.agent.mode) - l.info("stream", { - modelID: input.model.id, - providerID: input.model.providerID, - }) - const [language, cfg, provider, info] = await Effect.runPromise( - Effect.gen(function* () { - const auth = yield* Auth.Service - const cfg = yield* Config.Service - const provider = yield* Provider.Service - return yield* Effect.all( - [ - provider.getLanguage(input.model), - cfg.get(), - provider.getProvider(input.model.providerID), - auth.get(input.model.providerID), - ], - { concurrency: "unbounded" }, - ) - }).pipe(Effect.provide(Layer.mergeAll(Auth.defaultLayer, Config.defaultLayer, Provider.defaultLayer))), - ) - // TODO: move this to a proper hook - const isOpenaiOauth = provider.id === "openai" && info?.type === "oauth" - - const system: string[] = [] - system.push( - [ - // use agent prompt otherwise provider prompt - ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), - // any custom prompt passed into this call - ...input.system, - // any custom prompt from last user message - ...(input.user.system ? [input.user.system] : []), - ] - .filter((x) => x) - .join("\n"), - ) - - const header = system[0] - await Plugin.trigger( - "experimental.chat.system.transform", - { sessionID: input.sessionID, model: input.model }, - { system }, - ) - // rejoin to maintain 2-part structure for caching if header unchanged - if (system.length > 2 && system[0] === header) { - const rest = system.slice(1) - system.length = 0 - system.push(header, rest.join("\n")) - } - - const variant = - !input.small && input.model.variants && input.user.model.variant - ? input.model.variants[input.user.model.variant] - : {} - const base = input.small - ? ProviderTransform.smallOptions(input.model) - : ProviderTransform.options({ - model: input.model, - sessionID: input.sessionID, - providerOptions: provider.options, - }) - const options: Record = pipe( - base, - mergeDeep(input.model.options), - mergeDeep(input.agent.options), - mergeDeep(variant), - ) - if (isOpenaiOauth) { - options.instructions = system.join("\n") - } - - const isWorkflow = language instanceof GitLabWorkflowLanguageModel - const messages = isOpenaiOauth - ? input.messages - : isWorkflow - ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] - - const params = await Plugin.trigger( - "chat.params", - { - sessionID: input.sessionID, - agent: input.agent.name, - model: input.model, - provider, - message: input.user, - }, - { - temperature: input.model.capabilities.temperature - ? (input.agent.temperature ?? ProviderTransform.temperature(input.model)) - : undefined, - topP: input.agent.topP ?? ProviderTransform.topP(input.model), - topK: ProviderTransform.topK(input.model), - maxOutputTokens: ProviderTransform.maxOutputTokens(input.model), - options, - }, - ) - - const { headers } = await Plugin.trigger( - "chat.headers", - { - sessionID: input.sessionID, - agent: input.agent.name, - model: input.model, - provider, - message: input.user, - }, - { - headers: {}, - }, - ) - - const tools = resolveTools(input) - - // LiteLLM and some Anthropic proxies require the tools parameter to be present - // when message history contains tool calls, even if no tools are being used. - // Add a dummy tool that is never called to satisfy this validation. - // This is enabled for: - // 1. Providers with "litellm" in their ID or API ID (auto-detected) - // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways) - const isLiteLLMProxy = - provider.options?.["litellmProxy"] === true || - input.model.providerID.toLowerCase().includes("litellm") || - input.model.api.id.toLowerCase().includes("litellm") - - // LiteLLM/Bedrock rejects requests where the message history contains tool - // calls but no tools param is present. When there are no active tools (e.g. - // during compaction), inject a stub tool to satisfy the validation requirement. - // The stub description explicitly tells the model not to call it. - if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) { - tools["_noop"] = tool({ - description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", - inputSchema: jsonSchema({ - type: "object", - properties: { - reason: { type: "string", description: "Unused" }, - }, - }), - execute: async () => ({ output: "", title: "", metadata: {} }), - }) - } - - // Wire up toolExecutor for DWS workflow models so that tool calls - // from the workflow service are executed via opencode's tool system - // and results sent back over the WebSocket. - if (language instanceof GitLabWorkflowLanguageModel) { - const workflowModel = language as GitLabWorkflowLanguageModel & { - sessionID?: string - sessionPreapprovedTools?: string[] - approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }> - } - workflowModel.sessionID = input.sessionID - workflowModel.systemPrompt = system.join("\n") - workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { - const t = tools[toolName] - if (!t || !t.execute) { - return { result: "", error: `Unknown tool: ${toolName}` } - } - try { - const result = await t.execute!(JSON.parse(argsJson), { - toolCallId: _requestID, - messages: input.messages, - abortSignal: input.abort, - }) - const output = typeof result === "string" ? result : (result?.output ?? JSON.stringify(result)) - return { - result: output, - metadata: typeof result === "object" ? result?.metadata : undefined, - title: typeof result === "object" ? result?.title : undefined, - } - } catch (e: any) { - return { result: "", error: e.message ?? String(e) } - } - } - - const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) - workflowModel.sessionPreapprovedTools = Object.keys(tools).filter((name) => { - const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) - return !match || match.action !== "ask" - }) - - const approvedToolsForSession = new Set() - workflowModel.approvalHandler = Instance.bind(async (approvalTools) => { - const uniqueNames = [...new Set(approvalTools.map((t: { name: string }) => t.name))] as string[] - // Auto-approve tools that were already approved in this session - // (prevents infinite approval loops for server-side MCP tools) - if (uniqueNames.every((name) => approvedToolsForSession.has(name))) { - return { approved: true } - } - - const id = PermissionID.ascending() - let reply: Permission.Reply | undefined - let unsub: (() => void) | undefined - try { - unsub = Bus.subscribe(Permission.Event.Replied, (evt) => { - if (evt.properties.requestID === id) reply = evt.properties.reply - }) - const toolPatterns = approvalTools.map((t: { name: string; args: string }) => { - try { - const parsed = JSON.parse(t.args) as Record - const title = (parsed?.title ?? parsed?.name ?? "") as string - return title ? `${t.name}: ${title}` : t.name - } catch { - return t.name - } - }) - const uniquePatterns = [...new Set(toolPatterns)] as string[] - await Permission.ask({ - id, - sessionID: SessionID.make(input.sessionID), - permission: "workflow_tool_approval", - patterns: uniquePatterns, - metadata: { tools: approvalTools }, - always: uniquePatterns, - ruleset: [], - }) - for (const name of uniqueNames) approvedToolsForSession.add(name) - workflowModel.sessionPreapprovedTools = [...(workflowModel.sessionPreapprovedTools ?? []), ...uniqueNames] - return { approved: true } - } catch { - return { approved: false } - } finally { - unsub?.() - } - }) - } - - return streamText({ - onError(error) { - l.error("stream error", { - error, - }) - }, - async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && tools[lower]) { - l.info("repairing tool call", { - tool: failed.toolCall.toolName, - repaired: lower, - }) - return { - ...failed.toolCall, - toolName: lower, - } - } - return { - ...failed.toolCall, - input: JSON.stringify({ - tool: failed.toolCall.toolName, - error: failed.error.message, - }), - toolName: "invalid", - } - }, - temperature: params.temperature, - topP: params.topP, - topK: params.topK, - providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(tools).filter((x) => x !== "invalid"), - tools, - toolChoice: input.toolChoice, - maxOutputTokens: params.maxOutputTokens, - abortSignal: input.abort, - headers: { - ...(input.model.providerID.startsWith("opencode") - ? { - "x-opencode-project": Instance.project.id, - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": Flag.OPENCODE_CLIENT, - } - : { - "x-session-affinity": input.sessionID, - ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": `opencode/${Installation.VERSION}`, - }), - ...input.model.headers, - ...headers, - }, - maxRetries: input.retries ?? 0, - messages, - model: wrapLanguageModel({ - model: language, - middleware: [ - { - specificationVersion: "v3" as const, - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) - } - return args.params - }, - }, - ], + return Service.of({ stream }) }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID, - }, - }, - }) - } + ) + + export const defaultLayer = Layer.suspend(() => + layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Plugin.defaultLayer), + ), + ) function resolveTools(input: Pick) { const disabled = Permission.disabled( diff --git a/packages/opencode/src/tool/glob.ts b/packages/opencode/src/tool/glob.ts index a3ff5aef71..ea0fbf0134 100644 --- a/packages/opencode/src/tool/glob.ts +++ b/packages/opencode/src/tool/glob.ts @@ -40,6 +40,10 @@ export const GlobTool = Tool.define( let search = params.path ?? Instance.directory search = path.isAbsolute(search) ? search : path.resolve(Instance.directory, search) + const info = yield* fs.stat(search).pipe(Effect.catch(() => Effect.succeed(undefined))) + if (info?.type === "File") { + throw new Error(`glob path must be a directory: ${search}`) + } yield* assertExternalDirectoryEffect(ctx, search, { kind: "directory" }) const limit = 100 diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index 9b5143cec5..10a8de9170 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -51,19 +51,25 @@ export const GrepTool = Tool.define( ? (params.path ?? Instance.directory) : path.join(Instance.directory, params.path ?? "."), ) - yield* assertExternalDirectoryEffect(ctx, searchPath, { kind: "directory" }) + const info = yield* fs.stat(searchPath).pipe(Effect.catch(() => Effect.succeed(undefined))) + const cwd = info?.type === "Directory" ? searchPath : path.dirname(searchPath) + const file = info?.type === "Directory" ? undefined : [searchPath] + yield* assertExternalDirectoryEffect(ctx, searchPath, { + kind: info?.type === "Directory" ? "directory" : "file", + }) const result = yield* rg.search({ - cwd: searchPath, + cwd, pattern: params.pattern, glob: params.include ? [params.include] : undefined, + file, }) if (result.items.length === 0) return empty const rows = result.items.map((item) => ({ path: AppFileSystem.resolve( - path.isAbsolute(item.path.text) ? item.path.text : path.join(searchPath, item.path.text), + path.isAbsolute(item.path.text) ? item.path.text : path.join(cwd, item.path.text), ), line: item.line_number, text: item.lines.text, diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts index 11d212a086..cdc3493bd9 100644 --- a/packages/opencode/test/file/ripgrep.test.ts +++ b/packages/opencode/test/file/ripgrep.test.ts @@ -76,6 +76,25 @@ describe("Ripgrep.Service", () => { expect(result.items[0]?.lines.text).toContain("needle") }) + test("search supports explicit file targets", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "match.ts"), "const value = 'needle'\n") + await Bun.write(path.join(dir, "skip.ts"), "const value = 'needle'\n") + }, + }) + + const file = path.join(tmp.path, "match.ts") + const result = await Effect.gen(function* () { + const rg = yield* Ripgrep.Service + return yield* rg.search({ cwd: tmp.path, pattern: "needle", file: [file] }) + }).pipe(Effect.provide(Ripgrep.defaultLayer), Effect.runPromise) + + expect(result.partial).toBe(false) + expect(result.items).toHaveLength(1) + expect(result.items[0]?.path.text).toBe(file) + }) + test("files returns stream of filenames", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 043e3257b6..9e3007f6dc 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -1,33 +1,77 @@ import { afterEach, test, expect } from "bun:test" import os from "os" +import { Cause, Effect, Exit, Fiber, Layer } from "effect" import { Bus } from "../../src/bus" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" import { Permission } from "../../src/permission" import { PermissionID } from "../../src/permission/schema" import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" +import { provideInstance, provideTmpdirInstance, tmpdir, tmpdirScoped } from "../fixture/fixture" +import { testEffect } from "../lib/effect" import { MessageID, SessionID } from "../../src/session/schema" +const bus = Bus.layer +const env = Layer.mergeAll(Permission.layer.pipe(Layer.provide(bus)), bus, CrossSpawnSpawner.defaultLayer) +const it = testEffect(env) + afterEach(async () => { await Instance.disposeAll() }) -async function rejectAll(message?: string) { - for (const req of await Permission.list()) { - await Permission.reply({ - requestID: req.id, - reply: "reject", - message, - }) - } +const rejectAll = (message?: string) => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (const req of yield* permission.list()) { + yield* permission.reply({ + requestID: req.id, + reply: "reject", + message, + }) + } + }) + +const waitForPending = (count: number) => + Effect.gen(function* () { + const permission = yield* Permission.Service + for (let i = 0; i < 100; i++) { + const list = yield* permission.list() + if (list.length === count) return list + yield* Effect.sleep("10 millis") + } + return yield* Effect.fail(new Error(`timed out waiting for ${count} pending permission request(s)`)) + }) + +const fail = (self: Effect.Effect) => + Effect.gen(function* () { + const exit = yield* self.pipe(Effect.exit) + if (Exit.isFailure(exit)) return Cause.squash(exit.cause) + throw new Error("expected permission effect to fail") + }) + +const ask = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.ask(input) + }) + +const reply = (input: Parameters[0]) => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.reply(input) + }) + +const list = () => + Effect.gen(function* () { + const permission = yield* Permission.Service + return yield* permission.list() + }) + +function withDir(options: { git?: boolean } | undefined, self: (dir: string) => Effect.Effect) { + return provideTmpdirInstance(self, options) } -async function waitForPending(count: number) { - for (let i = 0; i < 20; i++) { - const list = await Permission.list() - if (list.length === count) return list - await Bun.sleep(0) - } - return Permission.list() +function withProvided(dir: string) { + return (self: Effect.Effect) => self.pipe(provideInstance(dir)) } // fromConfig tests @@ -170,24 +214,19 @@ test("merge - preserves rule order", () => { }) test("merge - config permission overrides default ask", () => { - // Simulates: defaults have "*": "ask", config sets bash: "allow" const defaults: Permission.Ruleset = [{ permission: "*", pattern: "*", action: "ask" }] const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const merged = Permission.merge(defaults, config) - // Config's bash allow should override default ask expect(Permission.evaluate("bash", "ls", merged).action).toBe("allow") - // Other permissions should still be ask (from defaults) expect(Permission.evaluate("edit", "foo.ts", merged).action).toBe("ask") }) test("merge - config ask overrides default allow", () => { - // Simulates: defaults have bash: "allow", config sets bash: "ask" const defaults: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }] const merged = Permission.merge(defaults, config) - // Config's ask should override default allow expect(Permission.evaluate("bash", "ls", merged).action).toBe("ask") }) @@ -233,7 +272,6 @@ test("evaluate - last matching glob wins", () => { }) test("evaluate - order matters for specificity", () => { - // If more specific rule comes first, later wildcard overrides it const result = Permission.evaluate("edit", "src/components/Button.tsx", [ { permission: "edit", pattern: "src/components/*", action: "allow" }, { permission: "edit", pattern: "src/*", action: "deny" }, @@ -350,19 +388,16 @@ test("evaluate - wildcard permission fallback for unknown tool", () => { }) test("evaluate - permission patterns sorted by length regardless of object order", () => { - // specific permission listed before wildcard, but specific should still win const result = Permission.evaluate("bash", "rm", [ { permission: "bash", pattern: "*", action: "allow" }, { permission: "*", pattern: "*", action: "deny" }, ]) - // With flat list, last matching rule wins - so "*" matches bash and wins expect(result.action).toBe("deny") }) test("evaluate - merges multiple rulesets", () => { const config: Permission.Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }] const approved: Permission.Ruleset = [{ permission: "bash", pattern: "rm", action: "deny" }] - // approved comes after config, so rm should be denied const result = Permission.evaluate("bash", "rm", config, approved) expect(result.action).toBe("deny") }) @@ -419,8 +454,6 @@ test("disabled - does not disable when action is ask", () => { }) test("disabled - does not disable when specific allow after wildcard deny", () => { - // Tool is NOT disabled because a specific allow after wildcard deny means - // there's at least some usage allowed const result = Permission.disabled( ["bash"], [ @@ -478,12 +511,10 @@ test("disabled - specific allow overrides wildcard deny", () => { // ask tests -test("ask - resolves immediately when action is allow", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await Permission.ask({ +it.live("ask - resolves immediately when action is allow", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const result = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -492,17 +523,15 @@ test("ask - resolves immediately when action is allow", async () => { ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], }) expect(result).toBeUndefined() - }, - }) -}) + }), + ), +) -test("ask - throws RejectedError when action is deny", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect( - Permission.ask({ +it.live("ask - throws DeniedError when action is deny", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const err = yield* fail( + ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["rm -rf /"], @@ -510,39 +539,35 @@ test("ask - throws RejectedError when action is deny", async () => { always: [], ruleset: [{ permission: "bash", pattern: "*", action: "deny" }], }), - ).rejects.toBeInstanceOf(Permission.DeniedError) - }, - }) -}) + ) + expect(err).toBeInstanceOf(Permission.DeniedError) + }), + ), +) -test("ask - returns pending promise when action is ask", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const promise = Permission.ask({ +it.live("ask - stays pending when action is ask", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], metadata: {}, always: [], ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], - }) - // Promise should be pending, not resolved - expect(promise).toBeInstanceOf(Promise) - // Don't await - just verify it returns a promise - await rejectAll() - await promise.catch(() => {}) - }, - }) -}) + }).pipe(Effect.forkScoped) -test("ask - adds request to pending list", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ask = Permission.ask({ + expect(yield* waitForPending(1)).toHaveLength(1) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + ), +) + +it.live("ask - adds request to pending list", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -553,11 +578,11 @@ test("ask - adds request to pending list", async () => { callID: "call_test", }, ruleset: [], - }) + }).pipe(Effect.forkScoped) - const list = await Permission.list() - expect(list).toHaveLength(1) - expect(list[0]).toMatchObject({ + const items = yield* waitForPending(1) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["ls"], @@ -569,58 +594,58 @@ test("ask - adds request to pending list", async () => { }, }) - await rejectAll() - await ask.catch(() => {}) - }, - }) -}) + yield* rejectAll() + yield* Fiber.await(fiber) + }), + ), +) -test("ask - publishes asked event", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { +it.live("ask - publishes asked event", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const bus = yield* Bus.Service let seen: Permission.Request | undefined - const unsub = Bus.subscribe(Permission.Event.Asked, (event) => { + const unsub = yield* bus.subscribeCallback(Permission.Event.Asked, (event) => { seen = event.properties }) - const ask = Permission.ask({ - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["ls"], - metadata: { cmd: "ls" }, - always: ["ls"], - tool: { - messageID: MessageID.make("msg_test"), - callID: "call_test", - }, - ruleset: [], - }) + try { + const fiber = yield* ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: { cmd: "ls" }, + always: ["ls"], + tool: { + messageID: MessageID.make("msg_test"), + callID: "call_test", + }, + ruleset: [], + }).pipe(Effect.forkScoped) - expect(await Permission.list()).toHaveLength(1) - expect(seen).toBeDefined() - expect(seen).toMatchObject({ - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["ls"], - }) + expect(yield* waitForPending(1)).toHaveLength(1) + expect(seen).toBeDefined() + expect(seen).toMatchObject({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + }) - unsub() - await rejectAll() - await ask.catch(() => {}) - }, - }) -}) + yield* rejectAll() + yield* Fiber.await(fiber) + } finally { + unsub() + } + }), + ), +) // reply tests -test("reply - once resolves the pending ask", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise = Permission.ask({ +it.live("reply - once resolves the pending ask", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ id: PermissionID.make("per_test1"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -628,26 +653,19 @@ test("reply - once resolves the pending ask", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(1) + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("per_test1"), reply: "once" }) + yield* Fiber.join(fiber) + }), + ), +) - await Permission.reply({ - requestID: PermissionID.make("per_test1"), - reply: "once", - }) - - await expect(askPromise).resolves.toBeUndefined() - }, - }) -}) - -test("reply - reject throws RejectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise = Permission.ask({ +it.live("reply - reject throws RejectedError", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ id: PermissionID.make("per_test2"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -655,26 +673,22 @@ test("reply - reject throws RejectedError", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(1) + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("per_test2"), reply: "reject" }) - await Permission.reply({ - requestID: PermissionID.make("per_test2"), - reply: "reject", - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), + ), +) - await expect(askPromise).rejects.toBeInstanceOf(Permission.RejectedError) - }, - }) -}) - -test("reply - reject with message throws CorrectedError", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ask = Permission.ask({ +it.live("reply - reject with message throws CorrectedError", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const fiber = yield* ask({ id: PermissionID.make("per_test2b"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -682,72 +696,60 @@ test("reply - reject with message throws CorrectedError", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(1) - - await Permission.reply({ + yield* waitForPending(1) + yield* reply({ requestID: PermissionID.make("per_test2b"), reply: "reject", message: "Use a safer command", }) - const err = await ask.catch((err) => err) - expect(err).toBeInstanceOf(Permission.CorrectedError) - expect(err.message).toContain("Use a safer command") - }, - }) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err).toBeInstanceOf(Permission.CorrectedError) + expect(String(err)).toContain("Use a safer command") + } + }), + ), +) -test("reply - always persists approval and resolves", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise = Permission.ask({ - id: PermissionID.make("per_test3"), - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: ["ls"], - ruleset: [], - }) +it.live("reply - always persists approval and resolves", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + const fiber = yield* ask({ + id: PermissionID.make("per_test3"), + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: ["ls"], + ruleset: [], + }).pipe(run, Effect.forkScoped) - await waitForPending(1) + yield* waitForPending(1).pipe(run) + yield* reply({ requestID: PermissionID.make("per_test3"), reply: "always" }).pipe(run) + yield* Fiber.join(fiber) - await Permission.reply({ - requestID: PermissionID.make("per_test3"), - reply: "always", - }) + const result = yield* ask({ + sessionID: SessionID.make("session_test2"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(run) + expect(result).toBeUndefined() + }), +) - await expect(askPromise).resolves.toBeUndefined() - }, - }) - // Re-provide to reload state with stored permissions - await Instance.provide({ - directory: tmp.path, - fn: async () => { - // Stored approval should allow without asking - const result = await Permission.ask({ - sessionID: SessionID.make("session_test2"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }) - expect(result).toBeUndefined() - }, - }) -}) - -test("reply - reject cancels all pending for same session", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const askPromise1 = Permission.ask({ +it.live("reply - reject cancels all pending for same session", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const a = yield* ask({ id: PermissionID.make("per_test4a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -755,9 +757,9 @@ test("reply - reject cancels all pending for same session", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - const askPromise2 = Permission.ask({ + const b = yield* ask({ id: PermissionID.make("per_test4b"), sessionID: SessionID.make("session_same"), permission: "edit", @@ -765,33 +767,24 @@ test("reply - reject cancels all pending for same session", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(2) + yield* waitForPending(2) + yield* reply({ requestID: PermissionID.make("per_test4a"), reply: "reject" }) - // Catch rejections before they become unhandled - const result1 = askPromise1.catch((e) => e) - const result2 = askPromise2.catch((e) => e) + const [ea, eb] = yield* Effect.all([Fiber.await(a), Fiber.await(b)]) + expect(Exit.isFailure(ea)).toBe(true) + expect(Exit.isFailure(eb)).toBe(true) + if (Exit.isFailure(ea)) expect(Cause.squash(ea.cause)).toBeInstanceOf(Permission.RejectedError) + if (Exit.isFailure(eb)) expect(Cause.squash(eb.cause)).toBeInstanceOf(Permission.RejectedError) + }), + ), +) - // Reject the first one - await Permission.reply({ - requestID: PermissionID.make("per_test4a"), - reply: "reject", - }) - - // Both should be rejected - expect(await result1).toBeInstanceOf(Permission.RejectedError) - expect(await result2).toBeInstanceOf(Permission.RejectedError) - }, - }) -}) - -test("reply - always resolves matching pending requests in same session", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const a = Permission.ask({ +it.live("reply - always resolves matching pending requests in same session", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const a = yield* ask({ id: PermissionID.make("per_test5a"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -799,9 +792,9 @@ test("reply - always resolves matching pending requests in same session", async metadata: {}, always: ["ls"], ruleset: [], - }) + }).pipe(Effect.forkScoped) - const b = Permission.ask({ + const b = yield* ask({ id: PermissionID.make("per_test5b"), sessionID: SessionID.make("session_same"), permission: "bash", @@ -809,28 +802,22 @@ test("reply - always resolves matching pending requests in same session", async metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(2) + yield* waitForPending(2) + yield* reply({ requestID: PermissionID.make("per_test5a"), reply: "always" }) - await Permission.reply({ - requestID: PermissionID.make("per_test5a"), - reply: "always", - }) + yield* Fiber.join(a) + yield* Fiber.join(b) + expect(yield* list()).toHaveLength(0) + }), + ), +) - await expect(a).resolves.toBeUndefined() - await expect(b).resolves.toBeUndefined() - expect(await Permission.list()).toHaveLength(0) - }, - }) -}) - -test("reply - always keeps other session pending", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const a = Permission.ask({ +it.live("reply - always keeps other session pending", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const a = yield* ask({ id: PermissionID.make("per_test6a"), sessionID: SessionID.make("session_a"), permission: "bash", @@ -838,9 +825,9 @@ test("reply - always keeps other session pending", async () => { metadata: {}, always: ["ls"], ruleset: [], - }) + }).pipe(Effect.forkScoped) - const b = Permission.ask({ + const b = yield* ask({ id: PermissionID.make("per_test6b"), sessionID: SessionID.make("session_b"), permission: "bash", @@ -848,30 +835,37 @@ test("reply - always keeps other session pending", async () => { metadata: {}, always: [], ruleset: [], - }) + }).pipe(Effect.forkScoped) - await waitForPending(2) + yield* waitForPending(2) + yield* reply({ requestID: PermissionID.make("per_test6a"), reply: "always" }) - await Permission.reply({ - requestID: PermissionID.make("per_test6a"), - reply: "always", - }) + yield* Fiber.join(a) + expect((yield* list()).map((item) => item.id)).toEqual([PermissionID.make("per_test6b")]) - await expect(a).resolves.toBeUndefined() - expect((await Permission.list()).map((x) => x.id)).toEqual([PermissionID.make("per_test6b")]) + yield* rejectAll() + yield* Fiber.await(b) + }), + ), +) - await rejectAll() - await b.catch(() => {}) - }, - }) -}) +it.live("reply - publishes replied event", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const bus = yield* Bus.Service + let resolve!: (value: { sessionID: SessionID; requestID: PermissionID; reply: Permission.Reply }) => void + const seen = Effect.promise<{ + sessionID: SessionID + requestID: PermissionID + reply: Permission.Reply + }>( + () => + new Promise((res) => { + resolve = res + }), + ) -test("reply - publishes replied event", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ask = Permission.ask({ + const fiber = yield* ask({ id: PermissionID.make("per_test7"), sessionID: SessionID.make("session_test"), permission: "bash", @@ -879,183 +873,132 @@ test("reply - publishes replied event", async () => { metadata: {}, always: [], ruleset: [], + }).pipe(Effect.forkScoped) + + yield* waitForPending(1) + + const unsub = yield* bus.subscribeCallback(Permission.Event.Replied, (event) => { + resolve(event.properties) }) - await waitForPending(1) + try { + yield* reply({ requestID: PermissionID.make("per_test7"), reply: "once" }) + yield* Fiber.join(fiber) + expect(yield* seen).toEqual({ + sessionID: SessionID.make("session_test"), + requestID: PermissionID.make("per_test7"), + reply: "once", + }) + } finally { + unsub() + } + }), + ), +) - let seen: - | { - sessionID: SessionID - requestID: PermissionID - reply: Permission.Reply - } - | undefined - const unsub = Bus.subscribe(Permission.Event.Replied, (event) => { - seen = event.properties - }) +it.live("permission requests stay isolated by directory", () => + Effect.gen(function* () { + const one = yield* tmpdirScoped({ git: true }) + const two = yield* tmpdirScoped({ git: true }) + const runOne = withProvided(one) + const runTwo = withProvided(two) - await Permission.reply({ - requestID: PermissionID.make("per_test7"), - reply: "once", - }) + const a = yield* ask({ + id: PermissionID.make("per_dir_a"), + sessionID: SessionID.make("session_dir_a"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(runOne, Effect.forkScoped) - await expect(ask).resolves.toBeUndefined() - expect(seen).toEqual({ - sessionID: SessionID.make("session_test"), - requestID: PermissionID.make("per_test7"), - reply: "once", - }) - unsub() - }, - }) -}) + const b = yield* ask({ + id: PermissionID.make("per_dir_b"), + sessionID: SessionID.make("session_dir_b"), + permission: "bash", + patterns: ["pwd"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(runTwo, Effect.forkScoped) -test("permission requests stay isolated by directory", async () => { - await using one = await tmpdir({ git: true }) - await using two = await tmpdir({ git: true }) + const onePending = yield* waitForPending(1).pipe(runOne) + const twoPending = yield* waitForPending(1).pipe(runTwo) - const a = Instance.provide({ - directory: one.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_dir_a"), - sessionID: SessionID.make("session_dir_a"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }), - }) + expect(onePending).toHaveLength(1) + expect(twoPending).toHaveLength(1) + expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) + expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) - const b = Instance.provide({ - directory: two.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_dir_b"), - sessionID: SessionID.make("session_dir_b"), - permission: "bash", - patterns: ["pwd"], - metadata: {}, - always: [], - ruleset: [], - }), - }) + yield* reply({ requestID: onePending[0].id, reply: "reject" }).pipe(runOne) + yield* reply({ requestID: twoPending[0].id, reply: "reject" }).pipe(runTwo) - const onePending = await Instance.provide({ - directory: one.path, - fn: () => waitForPending(1), - }) - const twoPending = await Instance.provide({ - directory: two.path, - fn: () => waitForPending(1), - }) + yield* Fiber.await(a) + yield* Fiber.await(b) + }), +) - expect(onePending).toHaveLength(1) - expect(twoPending).toHaveLength(1) - expect(onePending[0].id).toBe(PermissionID.make("per_dir_a")) - expect(twoPending[0].id).toBe(PermissionID.make("per_dir_b")) +it.live("pending permission rejects on instance dispose", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + const fiber = yield* ask({ + id: PermissionID.make("per_dispose"), + sessionID: SessionID.make("session_dispose"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(run, Effect.forkScoped) - await Instance.provide({ - directory: one.path, - fn: () => Permission.reply({ requestID: onePending[0].id, reply: "reject" }), - }) - await Instance.provide({ - directory: two.path, - fn: () => Permission.reply({ requestID: twoPending[0].id, reply: "reject" }), - }) + expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) + yield* Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })) - await a.catch(() => {}) - await b.catch(() => {}) -}) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), +) -test("pending permission rejects on instance dispose", async () => { - await using tmp = await tmpdir({ git: true }) +it.live("pending permission rejects on instance reload", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) + const fiber = yield* ask({ + id: PermissionID.make("per_reload"), + sessionID: SessionID.make("session_reload"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [], + }).pipe(run, Effect.forkScoped) - const ask = Instance.provide({ - directory: tmp.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_dispose"), - sessionID: SessionID.make("session_dispose"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }), - }) - const result = ask.then( - () => "resolved" as const, - (err) => err, - ) + expect(yield* waitForPending(1).pipe(run)).toHaveLength(1) + yield* Effect.promise(() => Instance.reload({ directory: dir })) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await waitForPending(1) - expect(pending).toHaveLength(1) - await Instance.dispose() - }, - }) + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), +) - expect(await result).toBeInstanceOf(Permission.RejectedError) -}) +it.live("reply - does nothing for unknown requestID", () => + withDir({ git: true }, () => + Effect.gen(function* () { + yield* reply({ requestID: PermissionID.make("per_unknown"), reply: "once" }) + expect(yield* list()).toHaveLength(0) + }), + ), +) -test("pending permission rejects on instance reload", async () => { - await using tmp = await tmpdir({ git: true }) - - const ask = Instance.provide({ - directory: tmp.path, - fn: () => - Permission.ask({ - id: PermissionID.make("per_reload"), - sessionID: SessionID.make("session_reload"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [], - }), - }) - const result = ask.then( - () => "resolved" as const, - (err) => err, - ) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const pending = await waitForPending(1) - expect(pending).toHaveLength(1) - await Instance.reload({ directory: tmp.path }) - }, - }) - - expect(await result).toBeInstanceOf(Permission.RejectedError) -}) - -test("reply - does nothing for unknown requestID", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await Permission.reply({ - requestID: PermissionID.make("per_unknown"), - reply: "once", - }) - expect(await Permission.list()).toHaveLength(0) - }, - }) -}) - -test("ask - checks all patterns and stops on first deny", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - await expect( - Permission.ask({ +it.live("ask - checks all patterns and stops on first deny", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const err = yield* fail( + ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "rm -rf /"], @@ -1066,17 +1009,16 @@ test("ask - checks all patterns and stops on first deny", async () => { { permission: "bash", pattern: "rm *", action: "deny" }, ], }), - ).rejects.toBeInstanceOf(Permission.DeniedError) - }, - }) -}) + ) + expect(err).toBeInstanceOf(Permission.DeniedError) + }), + ), +) -test("ask - allows all patterns when all match allow rules", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const result = await Permission.ask({ +it.live("ask - allows all patterns when all match allow rules", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const result = yield* ask({ sessionID: SessionID.make("session_test"), permission: "bash", patterns: ["echo hello", "ls -la", "pwd"], @@ -1085,64 +1027,54 @@ test("ask - allows all patterns when all match allow rules", async () => { ruleset: [{ permission: "bash", pattern: "*", action: "allow" }], }) expect(result).toBeUndefined() - }, - }) -}) + }), + ), +) -test("ask - should deny even when an earlier pattern is ask", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const err = await Permission.ask({ - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["echo hello", "rm -rf /"], - metadata: {}, - always: [], - ruleset: [ - { permission: "bash", pattern: "echo *", action: "ask" }, - { permission: "bash", pattern: "rm *", action: "deny" }, - ], - }).then( - () => undefined, - (err) => err, +it.live("ask - should deny even when an earlier pattern is ask", () => + withDir({ git: true }, () => + Effect.gen(function* () { + const err = yield* fail( + ask({ + sessionID: SessionID.make("session_test"), + permission: "bash", + patterns: ["echo hello", "rm -rf /"], + metadata: {}, + always: [], + ruleset: [ + { permission: "bash", pattern: "echo *", action: "ask" }, + { permission: "bash", pattern: "rm *", action: "deny" }, + ], + }), ) expect(err).toBeInstanceOf(Permission.DeniedError) - expect(await Permission.list()).toHaveLength(0) - }, - }) -}) + expect(yield* list()).toHaveLength(0) + }), + ), +) -test("ask - abort should clear pending request", async () => { - await using tmp = await tmpdir({ git: true }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const ctl = new AbortController() - const ask = Permission.runPromise( - (svc) => - svc.ask({ - sessionID: SessionID.make("session_test"), - permission: "bash", - patterns: ["ls"], - metadata: {}, - always: [], - ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], - }), - { signal: ctl.signal }, - ) +it.live("ask - abort should clear pending request", () => + Effect.gen(function* () { + const dir = yield* tmpdirScoped({ git: true }) + const run = withProvided(dir) - await waitForPending(1) - ctl.abort() - await ask.catch(() => {}) + const fiber = yield* ask({ + id: PermissionID.make("per_reload"), + sessionID: SessionID.make("session_reload"), + permission: "bash", + patterns: ["ls"], + metadata: {}, + always: [], + ruleset: [{ permission: "bash", pattern: "*", action: "ask" }], + }).pipe(run, Effect.forkScoped) - try { - expect(await Permission.list()).toHaveLength(0) - } finally { - await rejectAll() - } - }, - }) -}) + const pending = yield* waitForPending(1).pipe(run) + expect(pending).toHaveLength(1) + yield* Effect.promise(() => Instance.reload({ directory: dir })) + + const exit = yield* Fiber.await(fiber) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Permission.RejectedError) + }), +) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 3974ca9810..cbf767b4bd 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -26,6 +26,12 @@ async function getModel(providerID: ProviderID, modelID: ModelID) { ) } +const llm = makeRuntime(LLM.Service, LLM.defaultLayer) + +async function drain(input: LLM.StreamInput) { + return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain)) +} + describe("session.llm.hasToolCalls", () => { test("returns false for empty messages array", () => { expect(LLM.hasToolCalls([])).toBe(false) @@ -355,20 +361,16 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make(providerID), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User - const stream = await LLM.stream({ + await drain({ user, sessionID, model: resolved, agent, system: ["You are a helpful assistant."], - abort: new AbortController().signal, messages: [{ role: "user", content: "Hello" }], tools: {}, }) - for await (const _ of stream.fullStream) { - } - const capture = await request const body = capture.body const headers = capture.headers @@ -393,80 +395,6 @@ describe("session.llm.stream", () => { }) }) - test("raw stream abort signal cancels provider response body promptly", async () => { - const server = state.server - if (!server) throw new Error("Server not initialized") - - const providerID = "alibaba" - const modelID = "qwen-plus" - const fixture = await loadFixture(providerID, modelID) - const model = fixture.model - const pending = waitStreamingRequest("/chat/completions") - - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - enabled_providers: [providerID], - provider: { - [providerID]: { - options: { - apiKey: "test-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - }), - ) - }, - }) - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const resolved = await getModel(ProviderID.make(providerID), ModelID.make(model.id)) - const sessionID = SessionID.make("session-test-raw-abort") - const agent = { - name: "test", - mode: "primary", - options: {}, - permission: [{ permission: "*", pattern: "*", action: "allow" }], - } satisfies Agent.Info - const user = { - id: MessageID.make("user-raw-abort"), - sessionID, - role: "user", - time: { created: Date.now() }, - agent: agent.name, - model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, - } satisfies MessageV2.User - - const ctrl = new AbortController() - const result = await LLM.stream({ - user, - sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - abort: ctrl.signal, - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) - - const iter = result.fullStream[Symbol.asyncIterator]() - await pending.request - await iter.next() - ctrl.abort() - - await Promise.race([pending.responseCanceled, timeout(500)]) - await Promise.race([pending.requestAborted, timeout(500)]).catch(() => undefined) - await iter.return?.() - }, - }) - }) - test("service stream cancellation cancels provider response body promptly", async () => { const server = state.server if (!server) throw new Error("Server not initialized") @@ -518,8 +446,7 @@ describe("session.llm.stream", () => { } satisfies MessageV2.User const ctrl = new AbortController() - const { runPromiseExit } = makeRuntime(LLM.Service, LLM.defaultLayer) - const run = runPromiseExit( + const run = llm.runPromiseExit( (svc) => svc .stream({ @@ -610,14 +537,13 @@ describe("session.llm.stream", () => { tools: { question: true }, } satisfies MessageV2.User - const stream = await LLM.stream({ + await drain({ user, sessionID, model: resolved, agent, permission: [{ permission: "question", pattern: "*", action: "allow" }], system: ["You are a helpful assistant."], - abort: new AbortController().signal, messages: [{ role: "user", content: "Hello" }], tools: { question: tool({ @@ -628,9 +554,6 @@ describe("session.llm.stream", () => { }, }) - for await (const _ of stream.fullStream) { - } - const capture = await request const tools = capture.body.tools as Array<{ function?: { name?: string } }> | undefined expect(tools?.some((item) => item.function?.name === "question")).toBe(true) @@ -728,20 +651,16 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, } satisfies MessageV2.User - const stream = await LLM.stream({ + await drain({ user, sessionID, model: resolved, agent, system: ["You are a helpful assistant."], - abort: new AbortController().signal, messages: [{ role: "user", content: "Hello" }], tools: {}, }) - for await (const _ of stream.fullStream) { - } - const capture = await request const body = capture.body @@ -847,13 +766,12 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, } satisfies MessageV2.User - const stream = await LLM.stream({ + await drain({ user, sessionID, model: resolved, agent, system: ["You are a helpful assistant."], - abort: new AbortController().signal, messages: [ { role: "user", @@ -871,9 +789,6 @@ describe("session.llm.stream", () => { tools: {}, }) - for await (const _ of stream.fullStream) { - } - const capture = await request expect(capture.url.pathname.endsWith("/responses")).toBe(true) }, @@ -972,20 +887,16 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make("minimax"), modelID: ModelID.make("MiniMax-M2.5") }, } satisfies MessageV2.User - const stream = await LLM.stream({ + await drain({ user, sessionID, model: resolved, agent, system: ["You are a helpful assistant."], - abort: new AbortController().signal, messages: [{ role: "user", content: "Hello" }], tools: {}, }) - for await (const _ of stream.fullStream) { - } - const capture = await request const body = capture.body @@ -1073,20 +984,16 @@ describe("session.llm.stream", () => { model: { providerID: ProviderID.make(providerID), modelID: resolved.id }, } satisfies MessageV2.User - const stream = await LLM.stream({ + await drain({ user, sessionID, model: resolved, agent, system: ["You are a helpful assistant."], - abort: new AbortController().signal, messages: [{ role: "user", content: "Hello" }], tools: {}, }) - for await (const _ of stream.fullStream) { - } - const capture = await request const body = capture.body const config = body.generationConfig as diff --git a/packages/opencode/test/tool/glob.test.ts b/packages/opencode/test/tool/glob.test.ts new file mode 100644 index 0000000000..092885ed18 --- /dev/null +++ b/packages/opencode/test/tool/glob.test.ts @@ -0,0 +1,81 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Cause, Effect, Exit, Layer } from "effect" +import { GlobTool } from "../../src/tool/glob" +import { SessionID, MessageID } from "../../src/session/schema" +import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner" +import { Ripgrep } from "../../src/file/ripgrep" +import { AppFileSystem } from "../../src/filesystem" +import { Truncate } from "../../src/tool/truncate" +import { Agent } from "../../src/agent/agent" +import { provideTmpdirInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +const it = testEffect( + Layer.mergeAll( + CrossSpawnSpawner.defaultLayer, + AppFileSystem.defaultLayer, + Ripgrep.defaultLayer, + Truncate.defaultLayer, + Agent.defaultLayer, + ), +) + +const ctx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "build", + abort: AbortSignal.any([]), + messages: [], + metadata: () => Effect.void, + ask: () => Effect.void, +} + +describe("tool.glob", () => { + it.live("matches files from a directory path", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + yield* Effect.promise(() => Bun.write(path.join(dir, "a.ts"), "export const a = 1\n")) + yield* Effect.promise(() => Bun.write(path.join(dir, "b.txt"), "hello\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const result = yield* glob.execute( + { + pattern: "*.ts", + path: dir, + }, + ctx, + ) + expect(result.metadata.count).toBe(1) + expect(result.output).toContain(path.join(dir, "a.ts")) + expect(result.output).not.toContain(path.join(dir, "b.txt")) + }), + ), + ) + + it.live("rejects exact file paths", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "a.ts") + yield* Effect.promise(() => Bun.write(file, "export const a = 1\n")) + const info = yield* GlobTool + const glob = yield* info.init() + const exit = yield* glob + .execute( + { + pattern: "*.ts", + path: file, + }, + ctx, + ) + .pipe(Effect.exit) + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const err = Cause.squash(exit.cause) + expect(err instanceof Error ? err.message : String(err)).toContain("glob path must be a directory") + } + }), + ), + ) +}) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 07ac231df0..678aeee3d4 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -90,4 +90,25 @@ describe("tool.grep", () => { }), ), ) + + it.live("supports exact file paths", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const file = path.join(dir, "test.txt") + yield* Effect.promise(() => Bun.write(file, "line1\nline2\nline3")) + const info = yield* GrepTool + const grep = yield* info.init() + const result = yield* grep.execute( + { + pattern: "line2", + path: file, + }, + ctx, + ) + expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(file) + expect(result.output).toContain("Line 2: line2") + }), + ), + ) })