refactor: kilo compat for v1.14.42

This commit is contained in:
marius-kilocode
2026-06-01 12:51:31 +02:00
parent f7816a9eeb
commit 7cc6080646
2512 changed files with 52930 additions and 242898 deletions
+5
View File
@@ -68,6 +68,11 @@ jobs:
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
- name: Publish unit reports
if: always()
uses: mikepenz/action-junit-report@v6
+1
View File
@@ -3,6 +3,7 @@ node_modules
.worktrees
.sst
.env
.env.local
.idea
.vscode
.codex
+5
View File
@@ -0,0 +1,5 @@
# Fake secret-looking strings used by HTTP recorder redaction tests.
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71
+1 -1
View File
@@ -1 +1 @@
v1.14.41
v1.14.42
+1 -1
View File
@@ -3,7 +3,7 @@
"provider": {},
"permission": {
"edit": {
"packages/opencode/migration/*": "deny",
"packages/opencode/migration/*": "ask",
},
},
"mcp": {},
+367 -285
View File
@@ -1,35 +1,62 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
import { useTerminalDimensions, type JSX } from "@opentui/solid"
import { useBindings, useKeymapSelector } from "@opentui/keymap/solid"
import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core"
import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras"
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
modal: "ctrl+shift+m",
screen: "ctrl+shift+o",
home: "escape,ctrl+h",
left: "left,h",
right: "right,l",
up: "up,k",
down: "down,j",
alert: "a",
confirm: "c",
prompt: "p",
select: "s",
modal_accept: "enter,return",
modal_close: "escape",
dialog_close: "escape",
local: "x",
local_push: "enter,return",
local_close: "q,backspace",
host: "z",
const command = {
modal: "smoke_modal",
screen: "smoke_screen",
alert: "smoke_alert",
confirm: "smoke_confirm",
prompt: "smoke_prompt",
select: "smoke_select",
host: "smoke_host",
home: "smoke_home",
toast: "smoke_toast",
dialog_close: "smoke_dialog_close",
local_push: "smoke_local_push",
local_pop: "smoke_local_pop",
screen_home: "smoke_screen_home",
screen_left: "smoke_screen_left",
screen_right: "smoke_screen_right",
screen_up: "smoke_screen_up",
screen_down: "smoke_screen_down",
screen_modal: "smoke_screen_modal",
screen_local: "smoke_screen_local",
screen_host: "smoke_screen_host",
screen_alert: "smoke_screen_alert",
screen_confirm: "smoke_screen_confirm",
screen_prompt: "smoke_screen_prompt",
screen_select: "smoke_screen_select",
modal_accept: "smoke_modal_accept",
modal_close: "smoke_modal_close",
}
type SmokeBindings = BindingConfig<Renderable, KeyEvent>
const defaultKeymap = {
[command.modal]: "ctrl+shift+m",
[command.screen]: "ctrl+shift+o",
[command.dialog_close]: "escape",
[command.local_push]: "enter,return",
[command.local_pop]: "escape,q,backspace",
[command.screen_home]: "escape,ctrl+h",
[command.screen_left]: "left,h",
[command.screen_right]: "right,l",
[command.screen_up]: "up,k",
[command.screen_down]: "down,j",
[command.screen_modal]: "ctrl+shift+m",
[command.screen_local]: "x",
[command.screen_host]: "z",
[command.screen_alert]: "a",
[command.screen_confirm]: "c",
[command.screen_prompt]: "p",
[command.screen_select]: "s",
[command.modal_accept]: "enter,return",
[command.modal_close]: "escape",
}
const pick = (value: unknown, fallback: string) => {
@@ -43,16 +70,14 @@ const num = (value: unknown, fallback: number) => {
return value
}
const rec = (value: unknown) => {
if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
const record = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value)
type Cfg = {
label: string
route: string
vignette: number
keybinds: Record<string, unknown> | undefined
keybinds: SmokeBindings | undefined
}
type Route = {
@@ -74,7 +99,7 @@ const cfg = (options: Record<string, unknown> | undefined) => {
label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: rec(options?.keybinds),
keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined,
}
}
@@ -85,7 +110,12 @@ const names = (input: Cfg) => {
}
}
type Keys = TuiKeybindSet
function createKeys(input: SmokeBindings | undefined) {
return createBindingLookup({ ...defaultKeymap, ...input })
}
type Keys = ReturnType<typeof createKeys>
const ui = {
panel: "#1d1d1d",
border: "#4a4a4a",
@@ -292,125 +322,174 @@ const Screen = (props: {
}
const pop = (base?: State) => {
const next = base ?? current(props.api, props.route)
const local = Math.max(0, next.local - 1)
set(local, next)
set(Math.max(0, next.local - 1), next)
}
const show = () => {
setTimeout(() => {
open()
}, 0)
}
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
const screenActive = () => props.api.route.current.name === props.route.screen
if (next.local > 0) {
if (evt.name === "escape" || props.keys.match("local_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
pop(next)
return
}
useBindings(() => ({
enabled: () => screenActive() && props.api.ui.dialog.open,
commands: [
{
name: command.dialog_close,
run() {
props.api.ui.dialog.clear()
},
},
],
bindings: props.keys.gather("smoke.dialog", [command.dialog_close]),
}))
if (props.keys.match("local_push", evt)) {
evt.preventDefault()
evt.stopPropagation()
push(next)
return
}
return
}
useBindings(() => ({
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0,
commands: [
{
name: command.local_push,
run() {
push(current(props.api, props.route))
},
},
{
name: command.local_pop,
run() {
pop(current(props.api, props.route))
},
},
],
bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]),
}))
if (props.keys.match("home", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return
}
useBindings(() => ({
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0,
commands: [
{
name: command.screen_home,
run() {
props.api.route.navigate("home")
},
},
{
name: command.screen_left,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
},
},
{
name: command.screen_right,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
},
},
{
name: command.screen_up,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
},
},
{
name: command.screen_down,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
},
},
{
name: command.screen_modal,
run() {
props.api.route.navigate(props.route.modal, current(props.api, props.route))
},
},
{
name: command.screen_local,
run() {
open()
},
},
{
name: command.screen_host,
run() {
host(props.api, props.input, skin)
},
},
{
name: command.screen_alert,
run() {
warn(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_confirm,
run() {
check(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_prompt,
run() {
entry(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_select,
run() {
picker(props.api, props.route, current(props.api, props.route))
},
},
],
bindings: props.keys.gather("smoke.screen", [
command.screen_home,
command.screen_left,
command.screen_right,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_local,
command.screen_host,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
]),
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [
command.screen_home,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
command.screen_local,
command.screen_host,
command.local_push,
command.local_pop,
],
})
if (props.keys.match("left", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
return
}
if (props.keys.match("right", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
return
}
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
return {
screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "",
screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "",
screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "",
screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "",
screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "",
screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "",
screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "",
screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "",
screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "",
screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "",
local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "",
local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "",
}
})
@@ -430,7 +509,7 @@ const Screen = (props: {
<b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span>
</text>
<text fg={skin.muted}>{props.keys.print("home")} home</text>
<text fg={skin.muted}>{shortcuts().screen_home} home</text>
</box>
<box flexDirection="row" gap={1} paddingBottom={1}>
@@ -477,7 +556,7 @@ const Screen = (props: {
<box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}>
{props.keys.print("up")} / {props.keys.print("down")} change value
{shortcuts().screen_up} / {shortcuts().screen_down} change value
</text>
</box>
) : null}
@@ -485,17 +564,16 @@ const Screen = (props: {
{value.tab === 2 ? (
<box flexDirection="column" gap={1}>
<text fg={skin.muted}>
{props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
{shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "}
confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select
</text>
<text fg={skin.muted}>
{props.keys.print("local")} local stack | {props.keys.print("host")} host stack
{shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack
</text>
<text fg={skin.muted}>
local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close
</text>
<text fg={skin.muted}>{props.keys.print("home")} returns home</text>
<text fg={skin.muted}>{shortcuts().screen_home} returns home</text>
</box>
) : null}
</box>
@@ -548,7 +626,7 @@ const Screen = (props: {
</text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}>
{props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
{shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close
</text>
<box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on />
@@ -571,20 +649,35 @@ const Modal = (props: {
const value = parse(props.params)
const skin = tone(props.api)
useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.modal) return
useBindings(() => ({
enabled: () => props.api.route.current.name === props.route.modal,
commands: [
{
name: command.modal_accept,
run() {
props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" })
},
},
{
name: command.modal_close,
run() {
props.api.route.navigate("home")
},
},
],
bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]),
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [command.modal, command.screen, command.modal_accept, command.modal_close],
})
if (props.keys.match("modal_accept", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
return {
modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "",
screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "",
modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "",
modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "",
}
})
@@ -595,10 +688,10 @@ const Modal = (props: {
<text fg={skin.text}>
<b>{props.input.label} modal</b>
</text>
<text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}>{shortcuts().modal} modal command</text>
<text fg={skin.muted}>{shortcuts().screen} screen command</text>
<text fg={skin.muted}>
{props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
{shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes
</text>
<box flexDirection="row" gap={1}>
<Btn
@@ -651,25 +744,8 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const Prompt = api.ui.Prompt
const Slot = api.ui.Slot
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
@@ -791,109 +867,115 @@ const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input)
api.command.register(() => [
{
title: `${input.label} modal`,
value: "plugin.smoke.modal",
keybind: keys.get("modal"),
category: "Plugin",
slash: {
name: "smoke",
api.keymap.registerLayer({
commands: [
{
name: command.modal,
title: `${input.label} modal`,
category: "Plugin",
namespace: "palette",
slashName: "smoke",
run() {
api.route.navigate(route.modal, { source: "command" })
},
},
onSelect: () => {
api.route.navigate(route.modal, { source: "command" })
{
name: command.screen,
title: `${input.label} screen`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-screen",
run() {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
},
},
{
title: `${input.label} screen`,
value: "plugin.smoke.screen",
keybind: keys.get("screen"),
category: "Plugin",
slash: {
name: "smoke-screen",
{
name: command.alert,
title: `${input.label} alert dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-alert",
run() {
warn(api, route, current(api, route))
},
},
onSelect: () => {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
{
name: command.confirm,
title: `${input.label} confirm dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-confirm",
run() {
check(api, route, current(api, route))
},
},
},
{
title: `${input.label} alert dialog`,
value: "plugin.smoke.alert",
category: "Plugin",
slash: {
name: "smoke-alert",
{
name: command.prompt,
title: `${input.label} prompt dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-prompt",
run() {
entry(api, route, current(api, route))
},
},
onSelect: () => {
warn(api, route, current(api, route))
{
name: command.select,
title: `${input.label} select dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-select",
run() {
picker(api, route, current(api, route))
},
},
},
{
title: `${input.label} confirm dialog`,
value: "plugin.smoke.confirm",
category: "Plugin",
slash: {
name: "smoke-confirm",
{
name: command.host,
title: `${input.label} host overlay`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-host",
run() {
host(api, input, tone(api))
},
},
onSelect: () => {
check(api, route, current(api, route))
{
name: command.home,
title: `${input.label} go home`,
category: "Plugin",
namespace: "palette",
enabled: () => api.route.current.name !== "home",
run() {
api.route.navigate("home")
},
},
},
{
title: `${input.label} prompt dialog`,
value: "plugin.smoke.prompt",
category: "Plugin",
slash: {
name: "smoke-prompt",
{
name: command.toast,
title: `${input.label} toast`,
category: "Plugin",
namespace: "palette",
run() {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
onSelect: () => {
entry(api, route, current(api, route))
},
},
{
title: `${input.label} select dialog`,
value: "plugin.smoke.select",
category: "Plugin",
slash: {
name: "smoke-select",
},
onSelect: () => {
picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
],
bindings: keys.gather("smoke.global", [
command.modal,
command.screen,
command.alert,
command.confirm,
command.prompt,
command.select,
command.host,
command.home,
command.toast,
]),
})
}
const tui: TuiPlugin = async (api, options, meta) => {
@@ -902,9 +984,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme")
const value = cfg(options ?? undefined)
const value = cfg(options)
const route = names(value)
const keys = api.keybind.create(bind, value.keybinds)
const keys = createKeys(value.keybinds)
const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post)
+5 -4
View File
@@ -7,10 +7,11 @@
"enabled": false,
"label": "workspace",
"keybinds": {
"modal": "ctrl+alt+m",
"screen": "ctrl+alt+o",
"home": "escape,ctrl+shift+h",
"dialog_close": "escape,q"
"smoke_modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m",
"smoke_dialog_close": "escape,q"
}
}
]
+77 -37
View File
@@ -29,7 +29,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -85,7 +85,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -111,6 +111,7 @@
"zod": "catalog:",
},
"devDependencies": {
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"@webgpu/types": "0.1.54",
"typescript": "catalog:",
@@ -119,7 +120,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -146,7 +147,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -170,7 +171,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -194,7 +195,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.14.41",
"version": "1.14.42",
"bin": {
"opencode": "./bin/opencode",
},
@@ -228,7 +229,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -282,7 +283,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -302,6 +303,7 @@
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tailwindcss/vite": "catalog:",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@typescript/native-preview": "catalog:",
"tailwindcss": "catalog:",
@@ -311,7 +313,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -325,9 +327,40 @@
"typescript": "catalog:",
},
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.14.42",
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.14.42",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
"aws4fetch": "1.0.20",
"effect": "catalog:",
},
"devDependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
"@opencode-ai/http-recorder": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
},
},
"packages/opencode": {
"name": "opencode",
"version": "1.14.41",
"version": "1.14.42",
"bin": {
"opencode": "./bin/opencode",
},
@@ -360,10 +393,6 @@
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/node-server": "1.19.11",
"@hono/node-ws": "1.3.0",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@octokit/graphql": "9.0.2",
@@ -379,6 +408,7 @@
"@opentelemetry/sdk-trace-base": "2.6.1",
"@opentelemetry/sdk-trace-node": "2.6.1",
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
@@ -403,8 +433,6 @@
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
"hono-openapi": "catalog:",
"ignore": "7.0.5",
"immer": "11.1.4",
"jsonc-parser": "3.3.1",
@@ -431,7 +459,6 @@
"xdg-basedir": "5.1.0",
"yargs": "18.0.0",
"zod": "catalog:",
"zod-to-json-schema": "3.24.5",
},
"devDependencies": {
"@babel/core": "7.28.4",
@@ -464,12 +491,11 @@
"typescript": "catalog:",
"vscode-languageserver-types": "3.17.5",
"why-is-node-running": "3.2.2",
"zod-to-json-schema": "3.24.5",
},
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"effect": "catalog:",
@@ -477,6 +503,7 @@
},
"devDependencies": {
"@opentui/core": "catalog:",
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
@@ -484,11 +511,13 @@
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.2.2",
"@opentui/solid": ">=0.2.2",
"@opentui/core": ">=0.2.6",
"@opentui/keymap": ">=0.2.6",
"@opentui/solid": ">=0.2.6",
},
"optionalPeers": [
"@opentui/core",
"@opentui/keymap",
"@opentui/solid",
],
},
@@ -504,7 +533,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -519,7 +548,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -554,7 +583,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -603,7 +632,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.14.41",
"version": "1.14.42",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -663,8 +692,9 @@
"@npmcli/arborist": "9.4.0",
"@octokit/rest": "22.0.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.2.6",
"@opentui/keymap": "0.2.6",
"@opentui/solid": "0.2.6",
"@pierre/diffs": "1.1.0-beta.18",
"@playwright/test": "1.59.1",
"@sentry/solid": "10.36.0",
@@ -1201,8 +1231,6 @@
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@hono/node-ws": ["@hono/node-ws@1.3.0", "", { "dependencies": { "ws": "^8.17.0" }, "peerDependencies": { "@hono/node-server": "^1.19.2", "hono": "^4.6.0" } }, "sha512-ju25YbbvLuXdqBCmLZLqnNYu1nbHIQjoyUqA8ApZOeL1k4skuiTcw5SW77/5SUYo2Xi2NVBJoVlfQurnKEp03Q=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -1547,6 +1575,10 @@
"@opencode-ai/function": ["@opencode-ai/function@workspace:packages/function"],
"@opencode-ai/http-recorder": ["@opencode-ai/http-recorder@workspace:packages/http-recorder"],
"@opencode-ai/llm": ["@opencode-ai/llm@workspace:packages/llm"],
"@opencode-ai/plugin": ["@opencode-ai/plugin@workspace:packages/plugin"],
"@opencode-ai/script": ["@opencode-ai/script@workspace:packages/script"],
@@ -1589,21 +1621,23 @@
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
"@opentui/core": ["@opentui/core@0.2.2", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.2", "@opentui/core-darwin-x64": "0.2.2", "@opentui/core-linux-arm64": "0.2.2", "@opentui/core-linux-x64": "0.2.2", "@opentui/core-win32-arm64": "0.2.2", "@opentui/core-win32-x64": "0.2.2" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-wxg1CD58SVrowu+WgbhZNi3UP/wWxPio2Kj2IeTjomoIE+6EXLxR8eCCxHYVuQUd9E4fknrKkY5HmiSsp6oPow=="],
"@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-tY5n3ZRQx+b0kyhQJJLsyJMeZ+0w4FV37YZc/Qqv3qvOqE9kZPw/7adR77FYwWDm/7fax94mLMrR8Y5bKUkDmw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-W/R7OnqY30FXcTG0tiP2JkQFmgtYbIte5afQ5PC12TliRoee1RqG3iCG6kY1jxW+3Vg6jge88uiSjUEDpeV2gA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-1pzTYFEZauYuw6AGycw2TYGtAlZVGjuUtSdxH1fP51kBPS3oVWduUY2j7GKREz3SU5NulvO2Wc6HWsm3feMqwQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-ucVwUtUYeOYGVFPBLbPoxzbrPdhD0PDyKNQ2X4n1AJ9jlQX4gqBZRcXMEF8hiXDjFxsikZwef7De0ciCcWvAMg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-MPhYdJNdxmC5Bqsq6sis/+VkjRgkEjm+bQ1Tl++NSKLuiTU32Re0ImcZlgHbe+LZtZoGMZHVSgZlkGd3oYXO2g=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-19BroLfn2h0RDYfJS5o96Fc8kYCDhRBcseIXtHIkoKIsKMxx62KiDLo/byVye6rp+yQRRB7Xkd2uWqsbdiWo9w=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
"@opentui/solid": ["@opentui/solid@0.2.2", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.2", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-ZBVfCoVAhcUGQWPAWOTdzuVldMaRkuPpCu4U1VZCqmIw9DtbCuiVr0WnDocDxKhJLbTu8bl3qEWtVCf6lTSi3w=="],
"@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
"@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -5559,6 +5593,10 @@
"@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="],
"@opencode-ai/llm/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
"@opencode-ai/llm/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
"@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="],
"@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="],
@@ -6625,6 +6663,8 @@
"@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="],
"@opencode-ai/llm/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="],
"@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-MHeO1KTmjYa+V4ZBYrQq93cYpjnkGfO9e3MOWwkzjVY=",
"aarch64-linux": "sha256-EqTRG7DrdKKT7CEvnaNk5VhjTRhlZ9juP9/Nnr3dJ+g=",
"aarch64-darwin": "sha256-c8dWd8Pgp5uIAOdYbHIeGKqWfkF/l4Ze7ArYUMvTNkE=",
"x86_64-darwin": "sha256-61NpSO0AZ4iZG19RQ6zg0SJec+VQE46WJKOdRrNofT0="
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
}
}
+12 -6
View File
@@ -10,6 +10,7 @@
"dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint",
"typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty && bun run script/setup-git.ts",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -31,8 +32,8 @@
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.2",
"@opentui/solid": "0.2.2",
"@opentui/core": "0.2.6",
"@opentui/solid": "0.2.6",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1",
@@ -76,7 +77,8 @@
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"solid-js": "1.9.12",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10"
"@lydell/node-pty": "1.2.0-beta.10",
"@opentui/keymap": "0.2.6"
}
},
"devDependencies": {
@@ -138,13 +140,17 @@
"fastify": ">=5.8.3",
"diff": "8.0.4",
"dompurify": "3.4.2",
"happy-dom": ">=20.8.9"
"happy-dom": ">=20.8.9",
"@opentui/core": "catalog:",
"@opentui/solid": "catalog:",
"solid-js": "catalog:"
},
"patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.3.5",
"version": "7.3.18",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.3.5",
"version": "7.3.18",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+2 -13
View File
@@ -1,5 +1,4 @@
import { Config } from "effect"
import { InstallationChannel } from "../installation/version"
function truthy(key: string) {
const value = process.env[key]?.toLowerCase()
@@ -11,10 +10,6 @@ function falsy(key: string) {
return value === "false" || value === "0"
}
// Channels that default to the new effect-httpapi server backend. The legacy
// hono backend remains the default for stable (`prod`/`latest`) installs.
const HTTPAPI_DEFAULT_ON_CHANNELS = new Set(["dev", "beta", "local"])
function number(key: string) {
const value = process.env[key]
if (!value) return undefined
@@ -76,7 +71,9 @@ export const Flag = {
KILO_EXPERIMENTAL_LSP_TY: truthy("KILO_EXPERIMENTAL_LSP_TY"),
KILO_EXPERIMENTAL_LSP_TOOL: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_LSP_TOOL"),
KILO_EXPERIMENTAL_PLAN_MODE: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_PLAN_MODE"),
KILO_EXPERIMENTAL_SCOUT: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_SCOUT"),
KILO_EXPERIMENTAL_MARKDOWN: !falsy("KILO_EXPERIMENTAL_MARKDOWN"),
KILO_ENABLE_PARALLEL: truthy("KILO_ENABLE_PARALLEL") || truthy("KILO_EXPERIMENTAL_PARALLEL"),
KILO_MODELS_URL: process.env["KILO_MODELS_URL"],
KILO_MODELS_PATH: process.env["KILO_MODELS_PATH"],
KILO_DISABLE_EMBEDDED_WEB_UI: truthy("KILO_DISABLE_EMBEDDED_WEB_UI"),
@@ -86,14 +83,6 @@ export const Flag = {
KILO_STRICT_CONFIG_DEPS: truthy("KILO_STRICT_CONFIG_DEPS"),
KILO_WORKSPACE_ID: process.env["KILO_WORKSPACE_ID"],
// Defaults to true on dev/beta/local channels so internal users exercise the
// new effect-httpapi server backend. Stable (`prod`/`latest`) installs stay
// on the legacy hono backend until the rollout is complete. An explicit env
// var ("true"/"1" or "false"/"0") always wins, providing an opt-in for
// stable users and an escape hatch for dev/beta users.
KILO_EXPERIMENTAL_HTTPAPI:
truthy("KILO_EXPERIMENTAL_HTTPAPI") ||
(!falsy("KILO_EXPERIMENTAL_HTTPAPI") && HTTPAPI_DEFAULT_ON_CHANNELS.has(InstallationChannel)),
KILO_EXPERIMENTAL_WORKSPACES: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_WORKSPACES"),
KILO_EXPERIMENTAL_EVENT_SYSTEM: KILO_EXPERIMENTAL || truthy("KILO_EXPERIMENTAL_EVENT_SYSTEM"),
+4
View File
@@ -20,6 +20,7 @@ const paths = {
data,
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
repos: path.join(data, "repos"),
cache,
config,
state,
@@ -37,6 +38,7 @@ await Promise.all([
fs.mkdir(Path.tmp, { recursive: true }),
fs.mkdir(Path.log, { recursive: true }),
fs.mkdir(Path.bin, { recursive: true }),
fs.mkdir(Path.repos, { recursive: true }),
])
export class Service extends Context.Service<Service, Interface>()("@opencode/Global") {}
@@ -50,6 +52,7 @@ export interface Interface {
readonly tmp: string
readonly bin: string
readonly log: string
readonly repos: string
}
export function make(input: Partial<Interface> = {}): Interface {
@@ -62,6 +65,7 @@ export function make(input: Partial<Interface> = {}): Interface {
tmp: Path.tmp,
bin: Path.bin,
log: Path.log,
repos: Path.repos,
...input,
}
}
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "1.14.41"
version = "1.14.42"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilocode"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.41/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.41/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.41/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.41/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.41/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v1.14.42/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+26
View File
@@ -0,0 +1,26 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.3.18",
"name": "@opencode-ai/http-recorder",
"type": "module",
"license": "MIT",
"private": true,
"scripts": {
"test": "bun test --timeout 30000",
"test:ci": "mkdir -p .artifacts/unit && bun test --timeout 30000 --reporter=junit --reporter-outfile=.artifacts/unit/junit.xml",
"typecheck": "tsgo --noEmit"
},
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@effect/platform-node": "catalog:",
"effect": "catalog:"
}
}
+108
View File
@@ -0,0 +1,108 @@
import { Context, Effect, FileSystem, Layer, PlatformError, Ref } from "effect"
import * as path from "node:path"
import { cassetteSecretFindings, type SecretFinding } from "./redaction"
import type { Cassette, CassetteMetadata, Interaction } from "./schema"
import { cassetteFor, cassettePath, DEFAULT_RECORDINGS_DIR, formatCassette, parseCassette } from "./storage"
export interface Entry {
readonly name: string
readonly path: string
}
export interface Interface {
readonly path: (name: string) => string
readonly read: (name: string) => Effect.Effect<Cassette, PlatformError.PlatformError>
readonly write: (name: string, cassette: Cassette) => Effect.Effect<void, PlatformError.PlatformError>
readonly append: (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) => Effect.Effect<
{
readonly cassette: Cassette
readonly findings: ReadonlyArray<SecretFinding>
},
PlatformError.PlatformError
>
readonly exists: (name: string) => Effect.Effect<boolean>
readonly list: () => Effect.Effect<ReadonlyArray<Entry>, PlatformError.PlatformError>
readonly scan: (cassette: Cassette) => ReadonlyArray<SecretFinding>
}
export class Service extends Context.Service<Service, Interface>()("@opencode-ai/http-recorder/Cassette") {}
export const layer = (options: { readonly directory?: string } = {}) =>
Layer.effect(
Service,
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem
const directory = options.directory ?? DEFAULT_RECORDINGS_DIR
const recorded = yield* Ref.make(new Map<string, ReadonlyArray<Interaction>>())
const pathFor = (name: string) => cassettePath(name, directory)
const walk = (directory: string): Effect.Effect<ReadonlyArray<string>, PlatformError.PlatformError> =>
Effect.gen(function* () {
const entries = yield* fileSystem
.readDirectory(directory)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
const nested = yield* Effect.forEach(entries, (entry) => {
const full = path.join(directory, entry)
return fileSystem.stat(full).pipe(
Effect.flatMap((stat) => (stat.type === "Directory" ? walk(full) : Effect.succeed([full]))),
Effect.catch(() => Effect.succeed([] as string[])),
)
})
return nested.flat()
})
const read = Effect.fn("Cassette.read")(function* (name: string) {
return parseCassette(yield* fileSystem.readFileString(pathFor(name)))
})
const write = Effect.fn("Cassette.write")(function* (name: string, cassette: Cassette) {
yield* fileSystem.makeDirectory(path.dirname(pathFor(name)), { recursive: true })
yield* fileSystem.writeFileString(pathFor(name), formatCassette(cassette))
})
const append = Effect.fn("Cassette.append")(function* (
name: string,
interaction: Interaction,
metadata: CassetteMetadata | undefined,
) {
const interactions = yield* Ref.updateAndGet(recorded, (previous) =>
new Map(previous).set(name, [...(previous.get(name) ?? []), interaction]),
)
const cassette = cassetteFor(name, interactions.get(name) ?? [], metadata)
const findings = cassetteSecretFindings(cassette)
if (findings.length === 0) yield* write(name, cassette)
return { cassette, findings }
})
const exists = Effect.fn("Cassette.exists")(function* (name: string) {
return yield* fileSystem.access(pathFor(name)).pipe(
Effect.as(true),
Effect.catch(() => Effect.succeed(false)),
)
})
const list = Effect.fn("Cassette.list")(function* () {
return (yield* walk(directory))
.filter((file) => file.endsWith(".json"))
.map((file) => ({
name: path
.relative(directory, file)
.replace(/\\/g, "/")
.replace(/\.json$/, ""),
path: file,
}))
.toSorted((a, b) => a.name.localeCompare(b.name))
})
return Service.of({ path: pathFor, read, write, append, exists, list, scan: cassetteSecretFindings })
}),
)
export const defaultLayer = layer()
export * as Cassette from "./cassette"
+95
View File
@@ -0,0 +1,95 @@
import { Option } from "effect"
import { Headers, HttpBody, HttpClientRequest, UrlParams } from "effect/unstable/http"
import { decodeJson } from "./matching"
import { REDACTED, redactUrl, secretFindings } from "./redaction"
import { httpInteractions, type Cassette, type RequestSnapshot } from "./schema"
const safeText = (value: unknown) => {
if (value === undefined) return "undefined"
if (secretFindings(value).length > 0) return JSON.stringify(REDACTED)
const text = typeof value === "string" ? JSON.stringify(value) : JSON.stringify(value)
if (!text) return String(value)
return text.length > 300 ? `${text.slice(0, 300)}...` : text
}
const jsonBody = (body: string) => Option.getOrUndefined(decodeJson(body))
const valueDiffs = (expected: unknown, received: unknown, base = "$", limit = 8): ReadonlyArray<string> => {
if (Object.is(expected, received)) return []
if (
expected &&
received &&
typeof expected === "object" &&
typeof received === "object" &&
!Array.isArray(expected) &&
!Array.isArray(received)
) {
return [...new Set([...Object.keys(expected), ...Object.keys(received)])]
.toSorted()
.flatMap((key) =>
valueDiffs(
(expected as Record<string, unknown>)[key],
(received as Record<string, unknown>)[key],
`${base}.${key}`,
limit,
),
)
.slice(0, limit)
}
if (Array.isArray(expected) && Array.isArray(received)) {
return Array.from({ length: Math.max(expected.length, received.length) }, (_, index) => index)
.flatMap((index) => valueDiffs(expected[index], received[index], `${base}[${index}]`, limit))
.slice(0, limit)
}
return [`${base} expected ${safeText(expected)}, received ${safeText(received)}`]
}
const headerDiffs = (expected: Record<string, string>, received: Record<string, string>) =>
[...new Set([...Object.keys(expected), ...Object.keys(received)])].toSorted().flatMap((key) => {
if (expected[key] === received[key]) return []
if (expected[key] === undefined) return [` ${key} unexpected ${safeText(received[key])}`]
if (received[key] === undefined) return [` ${key} missing expected ${safeText(expected[key])}`]
return [` ${key} expected ${safeText(expected[key])}, received ${safeText(received[key])}`]
})
export const requestDiff = (expected: RequestSnapshot, received: RequestSnapshot) => {
const lines = []
if (expected.method !== received.method) {
lines.push("method:", ` expected ${expected.method}, received ${received.method}`)
}
if (expected.url !== received.url) {
lines.push("url:", ` expected ${expected.url}`, ` received ${received.url}`)
}
const headers = headerDiffs(expected.headers, received.headers)
if (headers.length > 0) lines.push("headers:", ...headers.slice(0, 8))
const expectedBody = jsonBody(expected.body)
const receivedBody = jsonBody(received.body)
const body =
expectedBody !== undefined && receivedBody !== undefined
? valueDiffs(expectedBody, receivedBody).map((line) => ` ${line}`)
: expected.body === received.body
? []
: [` expected ${safeText(expected.body)}, received ${safeText(received.body)}`]
if (body.length > 0) lines.push("body:", ...body)
return lines
}
export const mismatchDetail = (cassette: Cassette, incoming: RequestSnapshot) => {
const interactions = httpInteractions(cassette)
if (interactions.length === 0) return "cassette has no recorded HTTP interactions"
const ranked = interactions
.map((interaction, index) => ({ index, lines: requestDiff(interaction.request, incoming) }))
.toSorted((a, b) => a.lines.length - b.lines.length || a.index - b.index)
const best = ranked[0]
return ["no recorded interaction matched", `closest interaction: #${best.index + 1}`, ...best.lines].join("\n")
}
export const redactedErrorRequest = (request: HttpClientRequest.HttpClientRequest) =>
HttpClientRequest.makeWith(
request.method,
redactUrl(request.url),
UrlParams.empty,
Option.none(),
Headers.empty,
HttpBody.empty,
)
+211
View File
@@ -0,0 +1,211 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Effect, Layer, Option, Ref } from "effect"
import {
FetchHttpClient,
HttpClient,
HttpClientError,
HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http"
import { redactedErrorRequest, mismatchDetail, requestDiff } from "./diff"
import { defaultMatcher, decodeJson, type RequestMatcher } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import {
httpInteractions,
type Cassette,
type CassetteMetadata,
type HttpInteraction,
type ResponseSnapshot,
} from "./schema"
import * as CassetteService from "./cassette"
export const DEFAULT_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
const DEFAULT_RESPONSE_HEADERS: ReadonlyArray<string> = ["content-type"]
export type RecordReplayMode = "record" | "replay" | "passthrough"
export interface RecordReplayOptions {
readonly mode?: RecordReplayMode
readonly directory?: string
readonly metadata?: CassetteMetadata
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly responseHeaders?: ReadonlyArray<string>
readonly redactBody?: (body: unknown) => unknown
readonly dispatch?: "match" | "sequential"
readonly match?: RequestMatcher
}
const responseHeaders = (
response: HttpClientResponse.HttpClientResponse,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> | undefined,
) => {
const merged = redactHeaders(response.headers as Record<string, string>, allow, redact)
if (!merged["content-type"]) merged["content-type"] = "text/event-stream"
return merged
}
const BINARY_CONTENT_TYPES: ReadonlyArray<string> = ["vnd.amazon.eventstream", "octet-stream"]
const isBinaryContentType = (contentType: string | undefined) => {
if (!contentType) return false
const lower = contentType.toLowerCase()
return BINARY_CONTENT_TYPES.some((token) => lower.includes(token))
}
const captureResponseBody = (response: HttpClientResponse.HttpClientResponse, contentType: string | undefined) =>
isBinaryContentType(contentType)
? response.arrayBuffer.pipe(
Effect.map((bytes) => ({ body: Buffer.from(bytes).toString("base64"), bodyEncoding: "base64" as const })),
)
: response.text.pipe(Effect.map((body) => ({ body })))
const decodeResponseBody = (snapshot: ResponseSnapshot) =>
snapshot.bodyEncoding === "base64" ? Buffer.from(snapshot.body, "base64") : snapshot.body
const fixtureMissing = (request: HttpClientRequest.HttpClientRequest, name: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" not found. Run with RECORD=true to create it.`,
}),
})
const fixtureMismatch = (request: HttpClientRequest.HttpClientRequest, name: string, detail: string) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Fixture "${name}" does not match the current request: ${detail}. Run with RECORD=true to update it.`,
}),
})
const unsafeCassette = (
request: HttpClientRequest.HttpClientRequest,
name: string,
findings: ReadonlyArray<SecretFinding>,
) =>
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({
request: redactedErrorRequest(request),
description: `Refusing to write cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
}),
})
export const recordingLayer = (
name: string,
options: Omit<RecordReplayOptions, "directory"> = {},
): Layer.Layer<HttpClient.HttpClient, never, HttpClient.HttpClient | CassetteService.Service> =>
Layer.effect(
HttpClient.HttpClient,
Effect.gen(function* () {
const upstream = yield* HttpClient.HttpClient
const cassetteService = yield* CassetteService.Service
const requestHeadersAllow = options.requestHeaders ?? DEFAULT_REQUEST_HEADERS
const responseHeadersAllow = options.responseHeaders ?? DEFAULT_RESPONSE_HEADERS
const match = options.match ?? defaultMatcher
const mode = options.mode ?? "replay"
const sequential = options.dispatch === "sequential"
const replay = yield* Ref.make<Cassette | undefined>(undefined)
const cursor = yield* Ref.make(0)
const snapshotRequest = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
const raw = yield* Effect.promise(() => web.text())
const body = options.redactBody
? Option.match(decodeJson(raw), {
onNone: () => raw,
onSome: (parsed) => JSON.stringify(options.redactBody?.(parsed)),
})
: raw
return {
method: web.method,
url: redactUrl(web.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
Object.fromEntries(web.headers.entries()),
requestHeadersAllow,
options.redact?.headers,
),
body,
}
})
const selectInteraction = (cassette: Cassette, incoming: HttpInteraction["request"]) =>
Effect.gen(function* () {
const interactions = httpInteractions(cassette)
if (sequential) {
const index = yield* Ref.get(cursor)
const interaction = interactions[index]
if (!interaction)
return { interaction, detail: `interaction ${index + 1} of ${interactions.length} not recorded` }
if (!match(incoming, interaction.request)) {
return { interaction: undefined, detail: requestDiff(interaction.request, incoming).join("\n") }
}
yield* Ref.update(cursor, (n) => n + 1)
return { interaction, detail: "" }
}
const interaction = interactions.find((candidate) => match(incoming, candidate.request))
return { interaction, detail: interaction ? "" : mismatchDetail(cassette, incoming) }
})
const loadReplay = (request: HttpClientRequest.HttpClientRequest) =>
Effect.gen(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const cassette = yield* cassetteService.read(name).pipe(Effect.mapError(() => fixtureMissing(request, name)))
yield* Ref.set(replay, cassette)
return cassette
})
return HttpClient.make((request) => {
if (mode === "passthrough") return upstream.execute(request)
if (mode === "record") {
return Effect.gen(function* () {
const currentRequest = yield* snapshotRequest(request)
const response = yield* upstream.execute(request)
const headers = responseHeaders(response, responseHeadersAllow, options.redact?.headers)
const captured = yield* captureResponseBody(response, headers["content-type"])
const interaction: HttpInteraction = {
transport: "http",
request: currentRequest,
response: { status: response.status, headers, ...captured },
}
const result = yield* cassetteService.append(name, interaction, options.metadata).pipe(Effect.orDie)
const findings = result.findings
if (findings.length > 0) return yield* unsafeCassette(request, name, findings)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
)
})
}
return Effect.gen(function* () {
const cassette = yield* loadReplay(request)
const incoming = yield* snapshotRequest(request)
const { interaction, detail } = yield* selectInteraction(cassette, incoming)
if (!interaction) return yield* fixtureMismatch(request, name, detail)
return HttpClientResponse.fromWeb(
request,
new Response(decodeResponseBody(interaction.response), interaction.response),
)
})
})
}),
)
export const cassetteLayer = (name: string, options: RecordReplayOptions = {}): Layer.Layer<HttpClient.HttpClient> =>
recordingLayer(name, options).pipe(
Layer.provide(CassetteService.layer({ directory: options.directory })),
Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeFileSystem.layer),
)
+10
View File
@@ -0,0 +1,10 @@
export * from "./schema"
export * from "./redaction"
export * from "./matching"
export * from "./diff"
export * from "./storage"
export * from "./websocket"
export * from "./effect"
export * as Cassette from "./cassette"
export * as HttpRecorder from "."
+36
View File
@@ -0,0 +1,36 @@
import { Option, Schema } from "effect"
import type { RequestSnapshot } from "./schema"
const JsonValue = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownOption(JsonValue)
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value)
export const canonicalizeJson = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(canonicalizeJson)
if (isRecord(value)) {
return Object.fromEntries(
Object.keys(value)
.toSorted()
.map((key) => [key, canonicalizeJson(value[key])]),
)
}
return value
}
export type RequestMatcher = (incoming: RequestSnapshot, recorded: RequestSnapshot) => boolean
export const canonicalSnapshot = (snapshot: RequestSnapshot): string =>
JSON.stringify({
method: snapshot.method,
url: snapshot.url,
headers: canonicalizeJson(snapshot.headers),
body: Option.match(decodeJson(snapshot.body), {
onNone: () => snapshot.body,
onSome: canonicalizeJson,
}),
})
export const defaultMatcher: RequestMatcher = (incoming, recorded) =>
canonicalSnapshot(incoming) === canonicalSnapshot(recorded)
+116
View File
@@ -0,0 +1,116 @@
import type { Cassette } from "./schema"
export const REDACTED = "[REDACTED]"
const DEFAULT_REDACT_HEADERS = [
"authorization",
"cookie",
"proxy-authorization",
"set-cookie",
"x-api-key",
"x-amz-security-token",
"x-goog-api-key",
]
const DEFAULT_REDACT_QUERY = [
"access_token",
"api-key",
"api_key",
"apikey",
"code",
"key",
"signature",
"sig",
"token",
"x-amz-credential",
"x-amz-security-token",
"x-amz-signature",
]
const SECRET_PATTERNS: ReadonlyArray<{ readonly label: string; readonly pattern: RegExp }> = [
{ label: "bearer token", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}\b/i },
{ label: "API key", pattern: /\bsk-[A-Za-z0-9][A-Za-z0-9_-]{20,}\b/ },
{ label: "Anthropic API key", pattern: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/ },
{ label: "Google API key", pattern: /\bAIza[0-9A-Za-z_-]{20,}\b/ },
{ label: "AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/ },
{ label: "GitHub token", pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/ },
{ label: "private key", pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
]
const ENV_SECRET_NAMES = /(?:API|AUTH|BEARER|CREDENTIAL|KEY|PASSWORD|SECRET|TOKEN)/i
const SAFE_ENV_VALUES = new Set(["fixture", "test", "test-key"])
const envSecrets = () =>
Object.entries(process.env).flatMap(([name, value]) => {
if (!value) return []
if (!ENV_SECRET_NAMES.test(name)) return []
if (value.length < 12) return []
if (SAFE_ENV_VALUES.has(value.toLowerCase())) return []
return [{ name, value }]
})
const pathFor = (base: string, key: string) => (base ? `${base}.${key}` : key)
const stringEntries = (value: unknown, base = ""): ReadonlyArray<{ readonly path: string; readonly value: string }> => {
if (typeof value === "string") return [{ path: base, value }]
if (Array.isArray(value)) return value.flatMap((item, index) => stringEntries(item, `${base}[${index}]`))
if (value && typeof value === "object") {
return Object.entries(value).flatMap(([key, child]) => stringEntries(child, pathFor(base, key)))
}
return []
}
const redactionSet = (values: ReadonlyArray<string> | undefined, defaults: ReadonlyArray<string>) =>
new Set([...defaults, ...(values ?? [])].map((value) => value.toLowerCase()))
export type UrlRedactor = (url: string) => string
export const redactUrl = (
raw: string,
query: ReadonlyArray<string> = DEFAULT_REDACT_QUERY,
urlRedactor?: UrlRedactor,
) => {
if (!URL.canParse(raw)) return urlRedactor?.(raw) ?? raw
const url = new URL(raw)
if (url.username) url.username = REDACTED
if (url.password) url.password = REDACTED
const redacted = redactionSet(query, DEFAULT_REDACT_QUERY)
for (const key of [...url.searchParams.keys()]) {
if (redacted.has(key.toLowerCase())) url.searchParams.set(key, REDACTED)
}
return urlRedactor?.(url.toString()) ?? url.toString()
}
export const redactHeaders = (
headers: Record<string, string>,
allow: ReadonlyArray<string>,
redact: ReadonlyArray<string> = DEFAULT_REDACT_HEADERS,
) => {
const allowed = new Set(allow.map((name) => name.toLowerCase()))
const redacted = redactionSet(redact, DEFAULT_REDACT_HEADERS)
return Object.fromEntries(
Object.entries(headers)
.map(([name, value]) => [name.toLowerCase(), value] as const)
.filter(([name]) => allowed.has(name))
.map(([name, value]) => [name, redacted.has(name) ? REDACTED : value] as const)
.toSorted(([a], [b]) => a.localeCompare(b)),
)
}
export type SecretFinding = {
readonly path: string
readonly reason: string
}
export const secretFindings = (value: unknown): ReadonlyArray<SecretFinding> =>
stringEntries(value).flatMap((entry) => [
...SECRET_PATTERNS.filter((item) => item.pattern.test(entry.value)).map((item) => ({
path: entry.path,
reason: item.label,
})),
...envSecrets()
.filter((item) => entry.value.includes(item.value))
.map((item) => ({ path: entry.path, reason: `environment secret ${item.name}` })),
])
export const cassetteSecretFindings = (cassette: Cassette) => secretFindings(cassette)
+67
View File
@@ -0,0 +1,67 @@
import { Schema } from "effect"
export const RequestSnapshotSchema = Schema.Struct({
method: Schema.String,
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
})
export type RequestSnapshot = Schema.Schema.Type<typeof RequestSnapshotSchema>
export const ResponseSnapshotSchema = Schema.Struct({
status: Schema.Number,
headers: Schema.Record(Schema.String, Schema.String),
body: Schema.String,
bodyEncoding: Schema.optional(Schema.Literals(["text", "base64"])),
})
export type ResponseSnapshot = Schema.Schema.Type<typeof ResponseSnapshotSchema>
export const CassetteMetadataSchema = Schema.Record(Schema.String, Schema.Unknown)
export type CassetteMetadata = Schema.Schema.Type<typeof CassetteMetadataSchema>
export const HttpInteractionSchema = Schema.Struct({
transport: Schema.tag("http"),
request: RequestSnapshotSchema,
response: ResponseSnapshotSchema,
})
export type HttpInteraction = Schema.Schema.Type<typeof HttpInteractionSchema>
export const WebSocketFrameSchema = Schema.Union([
Schema.Struct({ kind: Schema.tag("text"), body: Schema.String }),
Schema.Struct({ kind: Schema.tag("binary"), body: Schema.String, bodyEncoding: Schema.Literal("base64") }),
])
export type WebSocketFrame = Schema.Schema.Type<typeof WebSocketFrameSchema>
export const WebSocketInteractionSchema = Schema.Struct({
transport: Schema.tag("websocket"),
open: Schema.Struct({
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String),
}),
client: Schema.Array(WebSocketFrameSchema),
server: Schema.Array(WebSocketFrameSchema),
})
export type WebSocketInteraction = Schema.Schema.Type<typeof WebSocketInteractionSchema>
export const InteractionSchema = Schema.Union([HttpInteractionSchema, WebSocketInteractionSchema]).pipe(
Schema.toTaggedUnion("transport"),
)
export type Interaction = Schema.Schema.Type<typeof InteractionSchema>
export const isHttpInteraction = InteractionSchema.guards.http
export const isWebSocketInteraction = InteractionSchema.guards.websocket
export const httpInteractions = (cassette: Cassette) => cassette.interactions.filter(isHttpInteraction)
export const webSocketInteractions = (cassette: Cassette) => cassette.interactions.filter(isWebSocketInteraction)
export const CassetteSchema = Schema.Struct({
version: Schema.Literal(1),
metadata: Schema.optional(CassetteMetadataSchema),
interactions: Schema.Array(InteractionSchema),
})
export type Cassette = Schema.Schema.Type<typeof CassetteSchema>
export const decodeCassette = Schema.decodeUnknownSync(CassetteSchema)
export const encodeCassette = Schema.encodeSync(CassetteSchema)
+34
View File
@@ -0,0 +1,34 @@
import { Option } from "effect"
import * as fs from "node:fs"
import * as path from "node:path"
import { encodeCassette, decodeCassette, type Cassette, type CassetteMetadata, type Interaction } from "./schema"
export const DEFAULT_RECORDINGS_DIR = path.resolve(process.cwd(), "test", "fixtures", "recordings")
export const cassettePath = (name: string, directory = DEFAULT_RECORDINGS_DIR) => path.join(directory, `${name}.json`)
export const metadataFor = (name: string, metadata: CassetteMetadata | undefined): CassetteMetadata => ({
name,
recordedAt: new Date().toISOString(),
...(metadata ?? {}),
})
export const cassetteFor = (
name: string,
interactions: ReadonlyArray<Interaction>,
metadata: CassetteMetadata | undefined,
): Cassette => ({
version: 1,
metadata: metadataFor(name, metadata),
interactions,
})
export const formatCassette = (cassette: Cassette) => `${JSON.stringify(encodeCassette(cassette), null, 2)}\n`
export const parseCassette = (raw: string) => decodeCassette(JSON.parse(raw))
export const hasCassetteSync = (name: string, options: { readonly directory?: string } = {}) => {
const file = cassettePath(name, options.directory)
if (!fs.existsSync(file)) return false
return Option.isSome(Option.liftThrowable(parseCassette)(fs.readFileSync(file, "utf8")))
}
+204
View File
@@ -0,0 +1,204 @@
import { Effect, Option, Ref, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import * as CassetteService from "./cassette"
import { canonicalizeJson, decodeJson } from "./matching"
import { redactHeaders, redactUrl, type SecretFinding } from "./redaction"
import { webSocketInteractions, type CassetteMetadata, type WebSocketFrame, type WebSocketInteraction } from "./schema"
export const DEFAULT_WEBSOCKET_REQUEST_HEADERS: ReadonlyArray<string> = ["content-type", "accept", "openai-beta"]
export interface WebSocketRequest {
readonly url: string
readonly headers: Headers.Headers
}
export interface WebSocketConnection<E> {
readonly sendText: (message: string) => Effect.Effect<void, E>
readonly messages: Stream.Stream<string | Uint8Array, E>
readonly close: Effect.Effect<void>
}
export interface WebSocketExecutor<E> {
readonly open: (request: WebSocketRequest) => Effect.Effect<WebSocketConnection<E>, E>
}
export interface WebSocketRecordReplayOptions<E> {
readonly name: string
readonly mode?: "record" | "replay" | "passthrough"
readonly metadata?: CassetteMetadata
readonly cassette: CassetteService.Interface
readonly live: WebSocketExecutor<E>
readonly redact?: {
readonly headers?: ReadonlyArray<string>
readonly query?: ReadonlyArray<string>
readonly url?: (url: string) => string
}
readonly requestHeaders?: ReadonlyArray<string>
readonly compareClientMessagesAsJson?: boolean
}
const headersRecord = (headers: Headers.Headers) =>
Object.fromEntries(
Object.entries(headers as Record<string, unknown>)
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
.toSorted(([a], [b]) => a.localeCompare(b)),
)
const openSnapshot = (
request: WebSocketRequest,
options: Pick<WebSocketRecordReplayOptions<never>, "redact" | "requestHeaders"> = {},
) => ({
url: redactUrl(request.url, options.redact?.query, options.redact?.url),
headers: redactHeaders(
headersRecord(request.headers),
options.requestHeaders ?? DEFAULT_WEBSOCKET_REQUEST_HEADERS,
options.redact?.headers,
),
})
const textFrame = (body: string): WebSocketFrame => ({ kind: "text", body })
const frameText = (frame: WebSocketFrame) => {
if (frame.kind === "text") return frame.body
return new TextDecoder().decode(Buffer.from(frame.body, "base64"))
}
const frameMessage = (frame: WebSocketFrame) =>
frame.kind === "text" ? frame.body : new Uint8Array(Buffer.from(frame.body, "base64"))
const receivedFrame = (message: string | Uint8Array): WebSocketFrame =>
typeof message === "string"
? textFrame(message)
: { kind: "binary", body: Buffer.from(message).toString("base64"), bodyEncoding: "base64" }
const unsafeCassette = (name: string, findings: ReadonlyArray<SecretFinding>) =>
new Error(
`Refusing to write WebSocket cassette "${name}" because it contains possible secrets: ${findings
.map((item) => `${item.path} (${item.reason})`)
.join(", ")}`,
)
const mismatch = (message: string, actual: unknown, expected: unknown) =>
new Error(`${message}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`)
const assertEqual = (message: string, actual: unknown, expected: unknown) =>
Effect.sync(() => {
if (JSON.stringify(actual) === JSON.stringify(expected)) return
throw mismatch(message, actual, expected)
})
const jsonOrText = (value: string) => Option.match(decodeJson(value), { onNone: () => value, onSome: canonicalizeJson })
const compareClientMessage = (actual: string, expected: WebSocketFrame | undefined, index: number, asJson: boolean) => {
if (!expected)
return Effect.sync(() => {
throw new Error(`Unexpected WebSocket client frame ${index + 1}: ${actual}`)
})
const expectedText = frameText(expected)
if (!asJson) return assertEqual(`WebSocket client frame ${index + 1}`, actual, expectedText)
return assertEqual(`WebSocket client JSON frame ${index + 1}`, jsonOrText(actual), jsonOrText(expectedText))
}
export const makeWebSocketExecutor = <E>(
options: WebSocketRecordReplayOptions<E>,
): Effect.Effect<WebSocketExecutor<E>, never, Scope.Scope> =>
Effect.gen(function* () {
const mode = options.mode ?? "replay"
if (mode === "passthrough") return options.live
if (mode === "record") {
return {
open: (request) =>
Effect.gen(function* () {
const client: WebSocketFrame[] = []
const server: WebSocketFrame[] = []
const connection = yield* options.live.open(request)
const closed = yield* Ref.make(false)
const closeOnce = Effect.gen(function* () {
if (yield* Ref.getAndSet(closed, true)) return
yield* connection.close
const result = yield* options.cassette
.append(
options.name,
{ transport: "websocket", open: openSnapshot(request, options), client, server },
options.metadata,
)
.pipe(Effect.orDie)
if (result.findings.length > 0) yield* Effect.die(unsafeCassette(options.name, result.findings))
})
return {
sendText: (message: string) =>
connection.sendText(message).pipe(Effect.tap(() => Effect.sync(() => client.push(textFrame(message))))),
messages: connection.messages.pipe(
Stream.map((message) => {
server.push(receivedFrame(message))
return message
}),
),
close: closeOnce,
}
}),
}
}
const replay = yield* Ref.make<{ readonly interactions: ReadonlyArray<WebSocketInteraction> } | undefined>(
undefined,
)
const cursor = yield* Ref.make(0)
yield* Effect.addFinalizer(() =>
Effect.gen(function* () {
const input = yield* Ref.get(replay)
if (!input) return
yield* assertEqual(
`Unused recorded WebSocket interactions in ${options.name}`,
yield* Ref.get(cursor),
input.interactions.length,
)
}),
)
const loadReplay = Effect.fn("WebSocketRecorder.loadReplay")(function* () {
const cached = yield* Ref.get(replay)
if (cached) return cached
const input = {
interactions: webSocketInteractions(yield* options.cassette.read(options.name).pipe(Effect.orDie)),
}
yield* Ref.set(replay, input)
return input
})
return {
open: (request) => {
return Effect.gen(function* () {
const input = yield* loadReplay()
const index = yield* Ref.getAndUpdate(cursor, (value) => value + 1)
const interaction = input.interactions[index]
if (!interaction) return yield* Effect.die(new Error(`No recorded WebSocket interaction for ${request.url}`))
yield* assertEqual(`WebSocket open frame ${index + 1}`, openSnapshot(request, options), interaction.open)
const messageIndex = yield* Ref.make(0)
return {
sendText: (message: string) =>
Effect.gen(function* () {
const current = yield* Ref.getAndUpdate(messageIndex, (value) => value + 1)
yield* compareClientMessage(
message,
interaction.client[current],
current,
options.compareClientMessagesAsJson === true,
)
}),
messages: Stream.fromIterable(interaction.server).pipe(Stream.map(frameMessage)),
close: Effect.gen(function* () {
yield* assertEqual(
`WebSocket client frame count for interaction ${index + 1}`,
yield* Ref.get(messageIndex),
interaction.client.length,
)
}),
}
})
},
}
})
@@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":1}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"first\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/echo",
"headers": {
"content-type": "application/json"
},
"body": "{\"step\":2}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"reply\":\"second\"}"
}
}
]
}
@@ -0,0 +1,41 @@
{
"version": 1,
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"pending\"}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://example.test/poll",
"headers": {
"content-type": "application/json"
},
"body": "{\"id\":\"job_1\"}"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"status\":\"complete\"}"
}
}
]
}
@@ -0,0 +1,320 @@
import { NodeFileSystem } from "@effect/platform-node"
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Exit, Scope, Stream } from "effect"
import { Headers, HttpBody, HttpClient, HttpClientRequest } from "effect/unstable/http"
import * as fs from "node:fs"
import * as os from "node:os"
import * as path from "node:path"
import { HttpRecorder } from "../src"
import { redactedErrorRequest } from "../src/diff"
const post = (url: string, body: object) =>
Effect.gen(function* () {
const http = yield* HttpClient.HttpClient
const request = HttpClientRequest.post(url, {
headers: { "content-type": "application/json" },
body: HttpBody.text(JSON.stringify(body), "application/json"),
})
const response = yield* http.execute(request)
return yield* response.text
})
const run = <A, E>(effect: Effect.Effect<A, E, HttpClient.HttpClient>) =>
Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer("record-replay/multi-step"))))
const runWith = <A, E>(
name: string,
options: HttpRecorder.RecordReplayOptions,
effect: Effect.Effect<A, E, HttpClient.HttpClient>,
) => Effect.runPromise(effect.pipe(Effect.provide(HttpRecorder.cassetteLayer(name, options))))
const runRecorder = <A, E>(effect: Effect.Effect<A, E, HttpRecorder.Cassette.Service | Scope.Scope>) =>
Effect.runPromise(
Effect.scoped(
effect.pipe(
Effect.provide(
HttpRecorder.Cassette.layer({ directory: fs.mkdtempSync(path.join(os.tmpdir(), "http-recorder-")) }),
),
Effect.provide(NodeFileSystem.layer),
),
),
)
const failureText = (exit: Exit.Exit<unknown, unknown>) => {
if (Exit.isSuccess(exit)) return ""
return Cause.prettyErrors(exit.cause).join("\n")
}
describe("http-recorder", () => {
test("redacts sensitive URL query parameters", () => {
expect(
HttpRecorder.redactUrl(
"https://example.test/path?key=secret-google-key&api_key=secret-openai-key&safe=value&X-Amz-Signature=secret-signature",
),
).toBe(
"https://example.test/path?key=%5BREDACTED%5D&api_key=%5BREDACTED%5D&safe=value&X-Amz-Signature=%5BREDACTED%5D",
)
})
test("redacts URL credentials", () => {
expect(HttpRecorder.redactUrl("https://user:password@example.test/path?safe=value")).toBe(
"https://%5BREDACTED%5D:%5BREDACTED%5D@example.test/path?safe=value",
)
})
test("applies custom URL redaction after built-in redaction", () => {
expect(
HttpRecorder.redactUrl("https://example.test/accounts/real-account/path?key=secret-key", undefined, (url) =>
url.replace("/accounts/real-account/", "/accounts/{account}/"),
),
).toBe("https://example.test/accounts/{account}/path?key=%5BREDACTED%5D")
})
test("redacts sensitive headers when allow-listed", () => {
expect(
HttpRecorder.redactHeaders(
{
authorization: "Bearer secret-token",
"content-type": "application/json",
"x-custom-token": "custom-secret",
"x-api-key": "secret-key",
"x-goog-api-key": "secret-google-key",
},
["authorization", "content-type", "x-api-key", "x-goog-api-key", "x-custom-token"],
["x-custom-token"],
),
).toEqual({
authorization: "[REDACTED]",
"content-type": "application/json",
"x-api-key": "[REDACTED]",
"x-custom-token": "[REDACTED]",
"x-goog-api-key": "[REDACTED]",
})
})
test("redacts error requests without retaining headers, params, or body", () => {
const request = HttpClientRequest.post("https://example.test/path", {
headers: { authorization: "Bearer super-secret" },
body: HttpBody.text("super-secret-body", "text/plain"),
}).pipe(HttpClientRequest.setUrlParam("api_key", "super-secret-key"))
expect(redactedErrorRequest(request).toJSON()).toMatchObject({
url: "https://example.test/path",
urlParams: { params: [] },
headers: {},
body: { _tag: "Empty" },
})
})
test("detects secret-looking values without returning the secret", () => {
expect(
HttpRecorder.cassetteSecretFindings({
version: 1,
interactions: [
{
transport: "http",
request: {
method: "POST",
url: "https://example.test/path?key=sk-123456789012345678901234",
headers: {},
body: JSON.stringify({ nested: "AIzaSyDHibiBRvJZLsFnPYPoiTwxY4ztQ55yqCE" }),
},
response: {
status: 200,
headers: {},
body: "Bearer abcdefghijklmnopqrstuvwxyz",
},
},
],
}),
).toEqual([
{ path: "interactions[0].request.url", reason: "API key" },
{ path: "interactions[0].request.body", reason: "Google API key" },
{ path: "interactions[0].response.body", reason: "bearer token" },
])
})
test("detects secret-looking values inside metadata", () => {
expect(
HttpRecorder.cassetteSecretFindings({
version: 1,
metadata: { token: "sk-123456789012345678901234" },
interactions: [],
}),
).toEqual([{ path: "metadata.token", reason: "API key" }])
})
test("formats websocket cassettes with shared metadata", () => {
const cassette = HttpRecorder.cassetteFor(
"websocket/basic",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
{ provider: "openai" },
)
expect(cassette.metadata).toMatchObject({ name: "websocket/basic", provider: "openai" })
expect(HttpRecorder.parseCassette(HttpRecorder.formatCassette(cassette))).toEqual(cassette)
})
test("replays websocket interactions from the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
yield* cassette.write(
"websocket/replay",
HttpRecorder.cassetteFor(
"websocket/replay",
[
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
undefined,
),
)
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/replay",
cassette,
compareClientMessagesAsJson: true,
live: { open: () => Effect.die(new Error("unexpected live WebSocket open")) },
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
const messages: Array<string | Uint8Array> = []
yield* connection.messages.pipe(Stream.runForEach((message) => Effect.sync(() => messages.push(message))))
yield* connection.close
expect(messages).toEqual([JSON.stringify({ type: "response.completed" })])
}),
)
})
test("records websocket interactions into the shared cassette service", async () => {
await runRecorder(
Effect.gen(function* () {
const cassette = yield* HttpRecorder.Cassette.Service
const executor = yield* HttpRecorder.makeWebSocketExecutor({
name: "websocket/record",
mode: "record",
metadata: { provider: "test" },
cassette,
live: {
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromIterable([JSON.stringify({ type: "response.completed" })]),
close: Effect.void,
}),
},
})
const connection = yield* executor.open({
url: "wss://example.test/realtime",
headers: Headers.fromInput({ "content-type": "application/json" }),
})
yield* connection.sendText(JSON.stringify({ type: "response.create" }))
yield* connection.messages.pipe(Stream.runDrain)
yield* connection.close
expect(yield* cassette.read("websocket/record")).toMatchObject({
metadata: { name: "websocket/record", provider: "test" },
interactions: [
{
transport: "websocket",
open: { url: "wss://example.test/realtime", headers: { "content-type": "application/json" } },
client: [{ kind: "text", body: JSON.stringify({ type: "response.create" }) }],
server: [{ kind: "text", body: JSON.stringify({ type: "response.completed" }) }],
},
],
})
}),
)
})
test("default matcher dispatches multi-interaction cassettes by request shape", async () => {
await run(
Effect.gen(function* () {
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
expect(yield* post("https://example.test/echo", { step: 1 })).toBe('{"reply":"first"}')
}),
)
})
test("sequential dispatch returns recorded responses in order for identical requests", async () => {
await runWith(
"record-replay/retry",
{ dispatch: "sequential" },
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"complete"}')
}),
)
})
test("default matcher returns the first match for identical requests", async () => {
await runWith(
"record-replay/retry",
{},
Effect.gen(function* () {
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
expect(yield* post("https://example.test/poll", { id: "job_1" })).toBe('{"status":"pending"}')
}),
)
})
test("sequential dispatch reports cursor exhaustion when more requests are made than recorded", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
yield* post("https://example.test/echo", { step: 2 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
}),
)
})
test("sequential dispatch still validates each recorded request", async () => {
await runWith(
"record-replay/multi-step",
{ dispatch: "sequential" },
Effect.gen(function* () {
yield* post("https://example.test/echo", { step: 1 })
const exit = yield* Effect.exit(post("https://example.test/echo", { step: 3 }))
expect(Exit.isFailure(exit)).toBe(true)
expect(failureText(exit)).toContain("$.step expected 2, received 3")
expect(yield* post("https://example.test/echo", { step: 2 })).toBe('{"reply":"second"}')
}),
)
})
test("mismatch diagnostics show closest redacted request differences", async () => {
await run(
Effect.gen(function* () {
const exit = yield* Effect.exit(
post("https://example.test/echo?api_key=secret-value", { step: 3, token: "sk-123456789012345678901234" }),
)
const message = failureText(exit)
expect(message).toContain("closest interaction: #1")
expect(message).toContain("url:")
expect(message).toContain("https://example.test/echo?api_key=%5BREDACTED%5D")
expect(message).toContain("body:")
expect(message).toContain("$.step expected 1, received 3")
expect(message).toContain('$.token expected undefined, received "[REDACTED]"')
expect(message).not.toContain("sk-123456789012345678901234")
}),
)
})
})
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/bun/tsconfig.json",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"noUncheckedIndexedAccess": false,
"plugins": [
{
"name": "@effect/language-service",
"transform": "@effect/language-service/transform",
"namespaceImportPackages": ["effect", "@effect/*"]
}
]
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
#Tue May 19 16:02:10 CEST 2026
gradle.version=9.4.1
@@ -1 +0,0 @@
9a1bc090-7640-4d24-8376-33ee6bb4d6a´
@@ -1 +0,0 @@
e56a4d59-a968-4687-988a-98a71a3b841²
@@ -1 +0,0 @@
˘Ý>°¦ĽRç‡Ů¨±ľ‰ˇęc#X
@@ -1 +0,0 @@
d2b9ec0f-4512-45a1-a92e-f68f9318c30
@@ -1 +0,0 @@
1293147f-7b02-4fd5-a5d8-9cccd89c665
@@ -1 +0,0 @@
³9'wG3~ˆóŠ6I¶]U?Nø
@@ -1,3 +0,0 @@
άúF×Ò¼hsÔ?¹weDX[>G$E[5ûÍ€+öÀ4ʇ—¤YÌ€¤×ç¤~I%ÎHo^)>Ã;1 +ŠõÆü‰Þcmæwºô߈Àj}¼¸¹”5S2‹é® ºú)–U°WÔŸÃ[5Õu
¡,½;>úŒú]éXé?p©Ä7aÞNeßJRy ¡ì@ÝľZ@³_Ó¼í|·L¤f€hIù•DÛ¨±ýKÀ$ÚÌ×4´B*mHó+cǵöFÆ•.3…UOÀ4kG3ú>l_§Žka‡ÑM1°';áÔÛà9]ÓO¥ë”hÄ
œ‘qìêÔï¯ÈqÌéaÂS5Ì!±qí"YÚe7©”‰‡È÷[…B5u•nbHyÂIó¯W—r¹SpȌض¹_ØÑ¤»ë¼ ›F(6v̼
@@ -1 +0,0 @@
5a96c892-e281-4fd6-988e-266070a42f1°
Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More