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/nix/hashes.json b/nix/hashes.json index 1261f7c945..8023162dd8 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=", - "aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=", - "aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=", - "x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA=" + "x86_64-linux": "sha256-fiMi8VxyMhNTaZf0ButrMEwT/ZmfeEg1T3c6HwUz8p4=", + "aarch64-linux": "sha256-1Mzjijq/INZGGEm4EerYN3hu1VxiQ8wuGg6t+XPDf6w=", + "aarch64-darwin": "sha256-3SH8Q2kK/F2kM29FmFUMR1aA23rSei+mPJliRIGfvCM=", + "x86_64-darwin": "sha256-RPsyoNXn84K93gunRFLsBvkZIQilfmUXdwkeieQjbd8=" } } 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 18101f191a..0cc13bace9 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -312,6 +312,7 @@ export namespace Ripgrep { glob?: string[] limit?: number follow?: boolean + file?: string[] }) => Effect.Effect<{ items: Item[]; partial: boolean }, PlatformError | Error> } @@ -333,6 +334,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") @@ -345,7 +347,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 }) @@ -387,6 +389,7 @@ export namespace Ripgrep { glob?: string[] limit?: number follow?: boolean + file?: string[] }) { return yield* Effect.scoped( Effect.gen(function* () { @@ -396,6 +399,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/compaction.ts b/packages/opencode/src/session/compaction.ts index b280971c76..c4934b625f 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -310,31 +310,51 @@ When constructing the summary, try to stick to this template: } if (!replay) { - const continueMsg = yield* session.updateMessage({ - id: MessageID.ascending(), - role: "user", - sessionID: input.sessionID, - time: { created: Date.now() }, - agent: userMessage.agent, - model: userMessage.model, - }) - const text = - (input.overflow - ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" - : "") + - "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." - yield* session.updatePart({ - id: PartID.ascending(), - messageID: continueMsg.id, - sessionID: input.sessionID, - type: "text", - synthetic: true, - text, - time: { - start: Date.now(), - end: Date.now(), - }, - }) + const info = yield* provider.getProvider(userMessage.model.providerID) + if ( + (yield* plugin.trigger( + "experimental.compaction.autocontinue", + { + sessionID: input.sessionID, + agent: userMessage.agent, + model: yield* provider.getModel(userMessage.model.providerID, userMessage.model.modelID), + provider: { + source: info.source, + info, + options: info.options, + }, + message: userMessage, + overflow: input.overflow === true, + }, + { enabled: true }, + )).enabled + ) { + const continueMsg = yield* session.updateMessage({ + id: MessageID.ascending(), + role: "user", + sessionID: input.sessionID, + time: { created: Date.now() }, + agent: userMessage.agent, + model: userMessage.model, + }) + const text = + (input.overflow + ? "The previous request exceeded the provider's size limit due to large media attachments. The conversation was compacted and media files were removed from context. If the user was asking about attached images or files, explain that the attachments were too large to process and suggest they try again with smaller or fewer files.\n\n" + : "") + + "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed." + yield* session.updatePart({ + id: PartID.ascending(), + messageID: continueMsg.id, + sessionID: input.sessionID, + type: "text", + synthetic: true, + text, + time: { + start: Date.now(), + end: Date.now(), + }, + }) + } } } 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/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2b0908ee9d..206f417d11 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -244,6 +244,20 @@ function plugin(ready: ReturnType) { }) } +function autocontinue(enabled: boolean) { + return Layer.mock(Plugin.Service)({ + trigger: (name: Name, _input: Input, output: Output) => { + if (name !== "experimental.compaction.autocontinue") return Effect.succeed(output) + return Effect.sync(() => { + ;(output as { enabled: boolean }).enabled = enabled + return output + }) + }, + list: () => Effect.succeed([]), + init: () => Effect.void, + }) +} + describe("session.compaction.isOverflow", () => { test("returns true when token count exceeds usable context", async () => { await using tmp = await tmpdir() @@ -671,6 +685,49 @@ describe("session.compaction.process", () => { }) }) + test("allows plugins to disable synthetic continue prompt", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const msg = await user(session.id, "hello") + const rt = runtime("continue", autocontinue(false), wide()) + try { + const msgs = await Session.messages({ sessionID: session.id }) + const result = await rt.runPromise( + SessionCompaction.Service.use((svc) => + svc.process({ + parentID: msg.id, + messages: msgs, + sessionID: session.id, + auto: true, + }), + ), + ) + + const all = await Session.messages({ sessionID: session.id }) + const last = all.at(-1) + + expect(result).toBe("continue") + expect(last?.info.role).toBe("assistant") + expect( + all.some( + (msg) => + msg.info.role === "user" && + msg.parts.some( + (part) => + part.type === "text" && part.synthetic && part.text.includes("Continue if you have next steps"), + ), + ), + ).toBe(false) + } finally { + await rt.dispose() + } + }, + }) + }) + test("replays the prior user turn on overflow when earlier context exists", async () => { await using tmp = await tmpdir() await Instance.provide({ 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") + }), + ), + ) }) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 49d995c6f7..d53c23a891 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -304,6 +304,24 @@ export interface Hooks { input: { sessionID: string }, output: { context: string[]; prompt?: string }, ) => Promise + /** + * Called after compaction succeeds and before a synthetic user + * auto-continue message is added. + * + * - `enabled`: Defaults to `true`. Set to `false` to skip the synthetic + * user "continue" turn. + */ + "experimental.compaction.autocontinue"?: ( + input: { + sessionID: string + agent: string + model: Model + provider: ProviderContext + message: UserMessage + overflow: boolean + }, + output: { enabled: boolean }, + ) => Promise "experimental.text.complete"?: ( input: { sessionID: string; messageID: string; partID: string }, output: { text: string },