mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-02 13:36:22 +00:00
Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63ed761687 | ||
|
|
0c408a524d | ||
|
|
3bf4c5cc2e | ||
|
|
e2a700dc90 | ||
|
|
ebad4bc84c | ||
|
|
d82b75f090 | ||
|
|
134d0ede4d | ||
|
|
4a2c024ea7 | ||
|
|
4cb56a829e | ||
|
|
41516c78c8 | ||
|
|
d259ae7163 | ||
|
|
bb381e8bdd | ||
|
|
81efb7f146 | ||
|
|
77c5f7a516 | ||
|
|
4a18a86653 | ||
|
|
c07905012f | ||
|
|
e067948dd5 | ||
|
|
64744ce034 | ||
|
|
840cd232a8 | ||
|
|
ce97dd2805 | ||
|
|
f00390665f |
No files matched your search
@@ -33,7 +33,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -133,6 +133,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
@@ -178,7 +179,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -204,7 +205,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +218,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +254,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +281,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +298,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +322,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +342,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -410,7 +411,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -455,7 +456,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +509,7 @@
|
||||
},
|
||||
"packages/gui-extensions": {
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -553,7 +554,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -572,7 +573,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -585,7 +586,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -599,7 +600,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -614,7 +615,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -653,7 +654,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -683,7 +684,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -698,7 +699,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -722,7 +723,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -743,7 +744,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -765,7 +766,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -800,7 +801,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -820,7 +821,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -854,7 +855,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -873,7 +874,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -919,7 +920,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -933,7 +934,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -967,7 +968,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1002,7 +1003,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1040,7 +1041,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1081,7 +1082,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+22
-2
@@ -13,7 +13,12 @@
|
||||
opencode,
|
||||
}:
|
||||
let
|
||||
electron = callPackage ./electron.nix { };
|
||||
electronPin =
|
||||
(lib.pipe ../packages/desktop/package.json [
|
||||
builtins.readFile
|
||||
builtins.fromJSON
|
||||
]).devDependencies.electron;
|
||||
electron = callPackage ./electron.nix { inherit electronPin; };
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
pname = "opencode-desktop";
|
||||
@@ -66,7 +71,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
''
|
||||
# https://github.com/electron/electron/issues/31121
|
||||
# mac builds use a .app bundle which doesnt have this issue
|
||||
+ lib.optionalString stdenv.isLinux ''
|
||||
+ lib.optionalString stdenv.hostPlatform.isLinux ''
|
||||
substituteInPlace \
|
||||
packages/desktop/src/main/windows/appearance.ts \
|
||||
packages/desktop/src/main/service/desktop-cli.ts \
|
||||
@@ -74,6 +79,7 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
'';
|
||||
|
||||
preBuild = ''
|
||||
echo "electron ${electron.version} from nixpkgs ${lib.version}, package.json pins ${electronPin}"
|
||||
cp -r "${electron.dist}" $HOME/.electron-dist
|
||||
chmod -R u+w $HOME/.electron-dist
|
||||
|
||||
@@ -89,8 +95,15 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
|
||||
export OPENCODE_CLI_DIST="$TMPDIR/desktop-cli"
|
||||
cli_package=$(bun -e 'import { getCurrentCli } from "./scripts/utils.ts"; console.log(getCurrentCli().package.replace("@opencode/", ""))')
|
||||
# copyBuiltCliToResources joins this dist with the npm package name getCurrentCli()
|
||||
# reports, not the Nix build's name. It reads only .version from the manifest and
|
||||
# writes it as opencode-cli.version beside the binary.
|
||||
mkdir -p "$OPENCODE_CLI_DIST/$cli_package/bin"
|
||||
cp ${lib.getExe opencode} "$OPENCODE_CLI_DIST/$cli_package/bin/opencode"
|
||||
# OPENCODE_VERSION is what the bundled CLI prints for --version, so the manifest
|
||||
# and the executable cannot drift.
|
||||
bun -e 'await Bun.write(process.argv[1], JSON.stringify({ version: process.env.OPENCODE_VERSION }) + "\n")' \
|
||||
"$OPENCODE_CLI_DIST/$cli_package/package.json"
|
||||
|
||||
bun run build
|
||||
npx electron-builder --dir \
|
||||
@@ -138,6 +151,13 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
"libc.musl-x86_64.so.1"
|
||||
];
|
||||
|
||||
passthru = {
|
||||
# electronVersion is what ships; electronPin is what packages/desktop/package.json
|
||||
# asks for. They differ whenever nixpkgs carries no release of the pinned minor.
|
||||
electronVersion = electron.version;
|
||||
inherit electronPin;
|
||||
};
|
||||
|
||||
meta = {
|
||||
description = "OpenCode Desktop App";
|
||||
mainProgram = "opencode-desktop";
|
||||
|
||||
+8
-10
@@ -1,12 +1,10 @@
|
||||
{ callPackage, path }:
|
||||
{ lib, pkgs, electronPin }:
|
||||
let
|
||||
version = (builtins.fromJSON (builtins.readFile ../packages/desktop/package.json)).devDependencies.electron;
|
||||
# Nixpkgs owns the release hashes, so bumping the pin no longer means editing this repo. Only the
|
||||
# major is delegated, so what gets built trails the pin whenever nixpkgs has not shipped it yet.
|
||||
# That is safe: the bundle ships one native addon, node-pty's Node-API prebuild, and
|
||||
# only the win32 WSL runtime loads it, so nothing in the main process binds the
|
||||
# Electron ABI.
|
||||
major = lib.versions.major electronPin;
|
||||
in
|
||||
(callPackage (path + "/pkgs/development/tools/electron/binary/generic.nix") { }) version {
|
||||
# Electron 42.10.1 SHASUMS256.txt; update with the desktop package version.
|
||||
aarch64-linux = "20e68d6c4e47f3ebf59de7c6b1f8b8bec6a6ebda6a451132f9b465f3f13ce467";
|
||||
x86_64-linux = "2452b27112d92387471fa2488aafac85d79ea3f2ee1216c0abd5150d6c12362b";
|
||||
aarch64-darwin = "ac7194a3dfd81930ba35355c01620262c1254752859b42dcb8f4b9e4d174a871";
|
||||
# fetchzip hashes the unpacked headers, not the release tarball.
|
||||
headers = "sha256-4eUy3BZVvxTl7KUOsxio7769lL6ag/ecbeK+qLURWMI=";
|
||||
}
|
||||
pkgs."electron_${major}-bin" or (throw "nixpkgs ${lib.version} carries no prebuilt electron ${major}: run `nix flake update nixpkgs`, or pin a major nixpkgs still carries")
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-g3k0cAFGqzmRYlcIkg1NDvlx1WxHYhnYPL0/a8E+qTg=",
|
||||
"aarch64-linux": "sha256-a+3ymqdxOONGe2Tpq4GUccl1b+Dwzxlb9LFXgE1gZ+0=",
|
||||
"aarch64-darwin": "sha256-h8xIzuMmaWfJqjHCO74xUDCWNKQLFrIGoKYZ+2TauYc="
|
||||
"x86_64-linux": "sha256-EEBz2IQ14YPShAzY13zpZ7btq/EuSw86pZ6nrizc9BI=",
|
||||
"aarch64-linux": "sha256-X7om4U4OlKL4OddEMcyKL+1DCQXc9/f1xzzopE16Zmk=",
|
||||
"aarch64-darwin": "sha256-3bejEuX3AGv4SR/16O8KjNSO2t9WDaWCHRbBXsJ6z8A="
|
||||
}
|
||||
}
|
||||
+22
-6
@@ -12,7 +12,7 @@
|
||||
installShellFiles,
|
||||
versionCheckHook,
|
||||
writableTmpDirAsHomeHook,
|
||||
node_modules ? callPackage ./node-modules.nix { },
|
||||
node_modules ? callPackage ./node_modules.nix { },
|
||||
}:
|
||||
stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
pname = "opencode";
|
||||
@@ -85,14 +85,30 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
||||
'';
|
||||
|
||||
postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) ''
|
||||
# trick yargs into also generating zsh completions
|
||||
# v2 dropped the `completion` subcommand; --completions is the global flag.
|
||||
# --completions also accepts sh, which emits the same script as bash.
|
||||
# staged to files, substitute below rejects anything that is not a regular file
|
||||
$out/bin/opencode --completions bash > opencode.bash
|
||||
$out/bin/opencode --completions zsh > _opencode
|
||||
$out/bin/opencode --completions fish > opencode.fish
|
||||
|
||||
installShellCompletion --cmd opencode \
|
||||
--bash <($out/bin/opencode completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode completion)
|
||||
--bash opencode.bash \
|
||||
--fish opencode.fish \
|
||||
--zsh _opencode
|
||||
|
||||
# OPENCODE_CLI_NAME is a build-time define, so the opencode2 copies are
|
||||
# renamed rather than regenerated. --replace-fail is a global literal
|
||||
# substitution, so any lowercase opencode that later appears in a
|
||||
# description or help text ships as opencode2 in the opencode2 copy.
|
||||
substitute opencode.bash opencode2.bash --replace-fail opencode opencode2
|
||||
substitute _opencode _opencode2 --replace-fail opencode opencode2
|
||||
substitute opencode.fish opencode2.fish --replace-fail opencode opencode2
|
||||
|
||||
installShellCompletion --cmd opencode2 \
|
||||
--bash <($out/bin/opencode2 completion) \
|
||||
--zsh <(SHELL=/bin/zsh $out/bin/opencode2 completion)
|
||||
--bash opencode2.bash \
|
||||
--fish opencode2.fish \
|
||||
--zsh _opencode2
|
||||
'';
|
||||
|
||||
nativeInstallCheckInputs = [
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -8,6 +8,29 @@ import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
for (const view of ["desktop", "mobile"] as const) {
|
||||
test(`offers Git initialization for a project without VCS (${view})`, async ({ page }) => {
|
||||
if (view === "mobile") await page.setViewportSize({ width: 390, height: 844 })
|
||||
const requests: { directory: string; provider?: string }[] = []
|
||||
const workspace = await openSession(page, {
|
||||
name: "ReviewWithoutGit",
|
||||
project: { vcs: undefined },
|
||||
onVcsInit: (input) => requests.push(input),
|
||||
})
|
||||
if (view === "desktop") await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
else await page.getByRole("tablist", { name: "Session view" }).getByRole("tab", { name: "Changes" }).click()
|
||||
|
||||
const panel = view === "desktop" ? page.locator("#review-panel") : page.locator('[data-component="session-review"]')
|
||||
await expect(panel.getByText("Track, review, and undo changes in this project")).toBeVisible()
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toHaveCount(0)
|
||||
const init = panel.getByRole("button", { name: "Create Git repository" })
|
||||
await init.click()
|
||||
await expect.poll(() => requests).toEqual([{ directory: workspace.directory, provider: "git" }])
|
||||
await expect(panel.getByRole("button", { name: "Git changes" })).toBeVisible()
|
||||
await expect(init).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("open file tab browses, searches, and tracks missing files", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const directory = "C:/OpenCode/ReviewOpenFile"
|
||||
|
||||
@@ -200,9 +200,11 @@ test("terminal stacks under review by default and spans the bottom when configur
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const review = document.querySelector("#review-panel")!.getBoundingClientRect()
|
||||
const terminal = document.querySelector("#terminal-panel")!.getBoundingClientRect()
|
||||
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')!
|
||||
const review = document.querySelector("#review-panel")?.getBoundingClientRect()
|
||||
const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect()
|
||||
const sidebar = document.querySelector('#review-panel [data-slot="session-review-v2-sidebar"]')
|
||||
// The side region appears before its lazy review content after reload.
|
||||
if (!review || !terminal || !sidebar) return false
|
||||
const gap = terminal.top - review.bottom
|
||||
return (
|
||||
terminal.left <= 9 &&
|
||||
|
||||
@@ -543,6 +543,26 @@ test.describe("Working", () => {
|
||||
await expect(working).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps Working instead of Used 1 Thought while reasoning is the only activity", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([reasoningPart("prt_only_thought", "Still considering the answer.")], { completed: false }),
|
||||
],
|
||||
settings: { timelineDetail: detailed },
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await expect(working).toBeVisible()
|
||||
await expect(group).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Used 1 Thought" })).toHaveCount(0)
|
||||
|
||||
await timeline.send(partUpdated(toolPart("prt_after_thought", "read", "running", { filePath: "package.json" })))
|
||||
await expect(group).toBeVisible()
|
||||
await expect(group).toHaveAttribute("data-timeline-part-ids", `${assistantID}:reasoning:0,prt_after_thought`)
|
||||
await expect(working).toBeVisible()
|
||||
})
|
||||
|
||||
for (const name of ["shell", "patch", "subagent"] as const) {
|
||||
test(`hides Working during ${name} input and execution, then restores it on completion`, async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, {
|
||||
|
||||
@@ -169,6 +169,7 @@ const Group = HttpApiGroup.make("mock")
|
||||
)
|
||||
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
|
||||
.add(HttpApiEndpoint.post("vcsInit", "/api/vcs/init", { success: NoContent, error: Unsupported }))
|
||||
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { query: Query, success: Json }))
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface MockServerConfig {
|
||||
cursor?: string
|
||||
}
|
||||
vcs?: { current: string; default: string }
|
||||
// Initializes the mock project's VCS; without a handler this mutation answers 501.
|
||||
onVcsInit?: (input: { directory: string; provider?: string }) => void
|
||||
vcsDiff?: unknown[] | ((input: { mode?: string }) => unknown[])
|
||||
// Benchmark latency only. Tests hold message pages with `beforeMessagesResponse`.
|
||||
messageDelay?: number
|
||||
@@ -817,6 +819,24 @@ function mockHandlers(
|
||||
location: location(config),
|
||||
data: { branch: config.vcs ?? { current: "main", default: "main" } },
|
||||
}),
|
||||
vcsInit: (ctx) => {
|
||||
if (!config.onVcsInit) return unsupported("initialize VCS", "onVcsInit")
|
||||
return Effect.sync(() => {
|
||||
const url = new URL(ctx.request.url, "http://localhost")
|
||||
const provider = url.searchParams.get("provider") ?? undefined
|
||||
config.onVcsInit?.({ directory: requestDirectory(config, ctx.request), provider })
|
||||
const project = config.project as { id: string; vcs?: string }
|
||||
project.vcs = provider ?? "git"
|
||||
state.emit([
|
||||
{
|
||||
id: "evt_vcs_initialized",
|
||||
type: "worktree.updated",
|
||||
created: Date.now(),
|
||||
data: { projectID: project.id },
|
||||
},
|
||||
])
|
||||
}).pipe(Effect.andThen(noContent))
|
||||
},
|
||||
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
|
||||
vcsBranches: () => Effect.succeed({ location: location(config), data: ["main"] }),
|
||||
vcsDiff: (ctx) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -39,6 +39,7 @@
|
||||
"@opentui/solid": "catalog:",
|
||||
"@parcel/watcher": "2.5.1",
|
||||
"@silvia-odwyer/photon-node": "0.3.4",
|
||||
"diff": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
type JsonRpcId,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient } from "@opencode/client/promise"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { Cause, Deferred, Effect, Ref, type Scope } from "effect"
|
||||
import { ACPCatalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { ACPService } from "./service"
|
||||
import { ACPSessions } from "./sessions"
|
||||
import { ACPTurn } from "./turn"
|
||||
@@ -34,7 +34,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
(ctx: AgentHandlerContext<Params>) =>
|
||||
Deferred.await(ready).pipe(Effect.flatMap((service) => call(service, ctx))),
|
||||
Effect.catchTags({
|
||||
ACPCatalogLoadError: (error) => ACPPromise.classify(error.cause),
|
||||
ACPCatalogLoadError: (error) => ACPClient.classify(error.cause),
|
||||
ACPCatalogNotReadyError: (error) => Effect.die(error),
|
||||
}),
|
||||
Effect.mapError((error) => (error instanceof RequestError ? error : ACPError.toRequestError(error))),
|
||||
@@ -115,7 +115,7 @@ export const connect = Effect.fnUntraced(function* (client: OpenCodeClient, stre
|
||||
const acp = ACPConnection.make(app, stream)
|
||||
const connection = acp.connection
|
||||
const sessions = yield* ACPSessions.make({ client, connection, catalog })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false })
|
||||
const capabilities = yield* Ref.make({ childSessionUpdates: false, formElicitation: false, compaction: false })
|
||||
const turn = yield* ACPTurn.make({ client, connection, sessions, catalog, capabilities })
|
||||
yield* Deferred.succeed(ready, ACPService.make({ client, connection, catalog, sessions, capabilities, turn }))
|
||||
return acp.agent
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { CommandInfo, ModelInfo, ModelRef, OpenCodeClient, OpenCodeEvent } from "@opencode/client/promise"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Context, Deferred, Effect, Exit, Schedule, Schema, Semaphore, Stream, SubscriptionRef } from "effect"
|
||||
import type { ConfigOptionProvider } from "./config-option"
|
||||
@@ -10,12 +13,12 @@ export const builtinCommands = new Map([
|
||||
|
||||
export type Catalog = {
|
||||
readonly providers: ConfigOptionProvider[]
|
||||
readonly models: ModelInfo[]
|
||||
readonly defaultModel: ModelRef
|
||||
readonly modes: Array<{ id: string; name: string; description?: string }>
|
||||
readonly defaultModeID: string
|
||||
readonly models: ReadonlyArray<Model.Info>
|
||||
readonly defaultModel: Model.Ref
|
||||
readonly modes: ReadonlyArray<{ id: Agent.ID; name: string; description?: string }>
|
||||
readonly defaultModeID: Agent.ID
|
||||
/** Server commands, without those shadowed by a built-in. */
|
||||
readonly commands: CommandInfo[]
|
||||
readonly commands: ReadonlyArray<Command.Info>
|
||||
}
|
||||
|
||||
export class NotReadyError extends Schema.TaggedError<NotReadyError>()("ACPCatalogNotReadyError", {
|
||||
@@ -82,7 +85,7 @@ export const make = Effect.fnUntraced(function* (client: OpenCodeClient) {
|
||||
})
|
||||
|
||||
// Subscribe before the first read so an update between the read and the subscription is not lost.
|
||||
yield* Stream.fromAsyncIterable(client.event.subscribe(), (cause) => cause).pipe(
|
||||
yield* client.event.subscribe().pipe(
|
||||
Stream.runForEach((event) => {
|
||||
if (event.type === "server.connected") return Deferred.succeed(connected, undefined)
|
||||
if (!reloadOn.has(event.type)) return Effect.void
|
||||
@@ -151,16 +154,15 @@ const load = (client: OpenCodeClient, cwd: string) =>
|
||||
|
||||
const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
const location = { directory: cwd }
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.tryPromise({
|
||||
try: (signal) =>
|
||||
Promise.all([
|
||||
client.model.list({ location }, { signal }),
|
||||
client.model.default({ location }, { signal }),
|
||||
client.agent.list({ location }, { signal }),
|
||||
client.command.list({ location }, { signal }),
|
||||
]),
|
||||
catch: (cause) => new LoadError({ cause }),
|
||||
})
|
||||
const [modelResult, defaultResult, agentResult, commandResult] = yield* Effect.all(
|
||||
[
|
||||
client.model.list({ location }),
|
||||
client.model.default({ location }),
|
||||
client.agent.list({ location }),
|
||||
client.command.list({ location }),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.mapError((cause) => new LoadError({ cause })))
|
||||
const models = modelResult.data.filter((model) => model.enabled)
|
||||
const preferred = defaultResult.data
|
||||
// Parallel reads can straddle initialization; select only from this model list.
|
||||
@@ -186,7 +188,7 @@ const read = Effect.fnUntraced(function* (client: OpenCodeClient, cwd: string) {
|
||||
} satisfies Catalog
|
||||
})
|
||||
|
||||
function providers(models: readonly ModelInfo[]): ConfigOptionProvider[] {
|
||||
function providers(models: ReadonlyArray<Model.Info>): ConfigOptionProvider[] {
|
||||
return Array.from(new Set(models.map((model) => model.providerID)))
|
||||
.toSorted()
|
||||
.map((providerID) => ({
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { ClientError } from "@opencode/client/effect"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
/** Keeps the OpenCode client failures ACP reports. Any other failure is a defect. */
|
||||
export function classify(error: unknown): Effect.Effect<never, ACPError.Error> {
|
||||
if (
|
||||
error instanceof ClientError &&
|
||||
HttpClientError.isHttpClientError(error.cause) &&
|
||||
isConnectionFailure(error.cause)
|
||||
)
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
if (error instanceof SessionNotFoundError)
|
||||
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: error.sessionID }))
|
||||
if (error instanceof InvalidRequestError)
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: error.message, field: error.field }))
|
||||
return Effect.die(error)
|
||||
}
|
||||
|
||||
// The client reads every body through the response stream or buffer, which report a failed read as a `DecodeError`
|
||||
// carrying the read's cause. Its other `DecodeError`s, for an undeclared status or content type, have no cause, and
|
||||
// a body that fails to parse or decode fails with a `SchemaError` instead.
|
||||
function isConnectionFailure(error: HttpClientError.HttpClientError) {
|
||||
return (
|
||||
error.reason._tag === "TransportError" || (error.reason._tag === "DecodeError" && error.reason.cause !== undefined)
|
||||
)
|
||||
}
|
||||
|
||||
/** The client rejects a malformed ID before sending it, so it is rejected here as the server would. */
|
||||
export function decodeSessionID(value: string) {
|
||||
return Schema.decodeUnknownEffect(Session.ID)(value).pipe(
|
||||
Effect.mapError(() => new ACPError.InvalidRequestError({ message: "Invalid session ID", field: "sessionID" })),
|
||||
)
|
||||
}
|
||||
|
||||
export * as ACPClient from "./client"
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
|
||||
const MarkerMeta = "opencode/compaction"
|
||||
|
||||
/** Message IDs follow core's compaction message projection, so live compactions match replayed ones. */
|
||||
export type Started = { readonly status: "started"; readonly messageId: string; readonly reason: "auto" | "manual" }
|
||||
|
||||
type Compaction =
|
||||
| Started
|
||||
| {
|
||||
readonly status: "completed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly summary: string
|
||||
}
|
||||
| {
|
||||
readonly status: "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error: SessionError.Error
|
||||
}
|
||||
|
||||
/** Each session's compaction that has started and not yet settled, by session ID. */
|
||||
export type Tracked = ReadonlyMap<string, Started>
|
||||
|
||||
type LifecycleEvent = Extract<
|
||||
OpenCodeEvent,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
type OpeningEvent = Extract<
|
||||
LifecycleEvent,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
// Core reports a cancelled or interrupted compaction as a failure with one of these error types.
|
||||
const Cancelled = new Set(["aborted", "compaction.interrupted"])
|
||||
|
||||
/**
|
||||
* Whether compactions go out as the standard session updates rather than the `_meta` marker. A child compaction
|
||||
* projected onto the parent session keeps the marker, since a standard update there would read as compaction of the
|
||||
* parent's own context.
|
||||
*/
|
||||
export function usesStandardUpdates(
|
||||
ctx: { readonly compaction: boolean; readonly childUpdates: boolean },
|
||||
child: boolean,
|
||||
) {
|
||||
return ctx.compaction && (!child || ctx.childUpdates)
|
||||
}
|
||||
|
||||
/** Tracks a lifecycle event and returns the updates it sends. */
|
||||
export function apply(event: LifecycleEvent, tracked: Tracked, standard: boolean) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const started = open(event)
|
||||
return { tracked: new Map(tracked).set(sessionID, started), updates: [update(started, standard)] }
|
||||
}
|
||||
const current = tracked.get(sessionID)
|
||||
const remaining = new Map(tracked)
|
||||
remaining.delete(sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
if (!current) return { tracked: remaining, updates: [] }
|
||||
const completed: Compaction = {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
}
|
||||
return { tracked: remaining, updates: [update(completed, standard)] }
|
||||
}
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet. A live
|
||||
// standard compaction still opens before it settles.
|
||||
const started = current ?? open(event)
|
||||
const failed: Compaction = { ...started, status: "failed", reason: event.data.reason, error: event.data.error }
|
||||
return {
|
||||
tracked: remaining,
|
||||
updates: [...(current || !standard ? [] : [update(started, standard)]), update(failed, standard)],
|
||||
}
|
||||
}
|
||||
|
||||
/** A summary chunk for the session's tracked compaction, if it has one and uses standard updates. */
|
||||
export function chunk(started: Started | undefined, text: string, standard: boolean): SessionUpdate | undefined {
|
||||
if (!started || !standard) return undefined
|
||||
return { sessionUpdate: "compaction_summary_chunk", compactionId: started.messageId, content: { type: "text", text } }
|
||||
}
|
||||
|
||||
/** Settles a compaction a cancelled turn left open the way core settles a cancelled one. */
|
||||
export function abandon(started: Started, standard: boolean) {
|
||||
return update({ ...started, status: "failed", error: { type: "aborted", message: "Compaction cancelled" } }, standard)
|
||||
}
|
||||
|
||||
/** A settled compaction as one terminal update. A running one has no live turn on this connection to settle it. */
|
||||
export function replay(message: Extract<SessionMessage.Info, { type: "compaction" }>, standard: boolean) {
|
||||
if (message.status === "running") return undefined
|
||||
const base = { messageId: message.id, reason: message.reason }
|
||||
return update(
|
||||
message.status === "failed"
|
||||
? { ...base, status: "failed", error: message.error }
|
||||
: { ...base, status: "completed", summary: message.summary },
|
||||
standard,
|
||||
)
|
||||
}
|
||||
|
||||
function open(event: OpeningEvent): Started {
|
||||
return {
|
||||
status: "started",
|
||||
messageId: event.data.inputID ?? SessionMessage.ID.fromEvent(event.id),
|
||||
reason: event.data.reason,
|
||||
}
|
||||
}
|
||||
|
||||
function update(compaction: Compaction, standard: boolean): SessionUpdate {
|
||||
if (!standard) {
|
||||
const marker = {
|
||||
status: compaction.status,
|
||||
messageId: compaction.messageId,
|
||||
reason: compaction.reason,
|
||||
...(compaction.status === "failed" ? { error: compaction.error } : {}),
|
||||
}
|
||||
return { sessionUpdate: "session_info_update", _meta: { [MarkerMeta]: marker } }
|
||||
}
|
||||
const compactionId = compaction.messageId
|
||||
if (compaction.status === "started")
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "in_progress" }
|
||||
if (compaction.status === "completed") {
|
||||
const summary = compaction.summary ? [{ type: "text" as const, text: compaction.summary }] : null
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "completed", summary }
|
||||
}
|
||||
if (Cancelled.has(compaction.error.type))
|
||||
return { sessionUpdate: "compaction_update", compactionId, status: "cancelled", summary: null }
|
||||
return {
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "failed",
|
||||
summary: null,
|
||||
error: errorMessage(compaction.error),
|
||||
}
|
||||
}
|
||||
|
||||
// Core reports a defect as `compaction.failed` with the pretty-printed cause, whose stack trace spans several lines.
|
||||
function errorMessage(error: SessionError.Error) {
|
||||
if (!error.message || (error.type === "compaction.failed" && error.message.includes("\n"))) return "Compaction failed"
|
||||
return error.message
|
||||
}
|
||||
|
||||
export * as ACPCompaction from "./compaction"
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { ModelRef } from "@opencode/client/promise"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import { builtinCommands, type Catalog } from "./catalog"
|
||||
|
||||
export const DEFAULT_VARIANT_VALUE = "default"
|
||||
@@ -29,8 +30,8 @@ export type ModelSelection = {
|
||||
|
||||
/** A session's model and mode. Unset fields follow the catalog defaults. */
|
||||
export type Selection = {
|
||||
readonly model?: ModelRef
|
||||
readonly modeID?: string
|
||||
readonly model?: Model.Ref
|
||||
readonly modeID?: Agent.ID
|
||||
}
|
||||
|
||||
export function currentModel(catalog: Catalog, selection: Selection) {
|
||||
|
||||
@@ -5,15 +5,13 @@ import {
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
type JsonRpcId,
|
||||
type RequestError,
|
||||
RequestError,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type SessionNotification,
|
||||
type Stream,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { Context, Deferred, Effect } from "effect"
|
||||
import type { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
/**
|
||||
* Completes once the response to the request being handled is written, so messages sent afterwards follow it.
|
||||
@@ -24,19 +22,16 @@ export const Responded = Context.Reference<Effect.Effect<void>>("@opencode/cli/a
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
readonly sessionUpdate: (params: SessionNotification) => Effect.Effect<void, RequestError>
|
||||
/** Interruption cancels the client's request. */
|
||||
readonly requestPermission: (
|
||||
params: RequestPermissionRequest,
|
||||
) => Effect.Effect<RequestPermissionResponse, ACPError.Error | RequestError>
|
||||
readonly extNotification: (
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
) => Effect.Effect<void, ACPError.Error | RequestError>
|
||||
) => Effect.Effect<RequestPermissionResponse, RequestError>
|
||||
readonly extNotification: (method: string, params: Record<string, unknown>) => Effect.Effect<void, RequestError>
|
||||
/** Interruption cancels the client's request. */
|
||||
readonly createElicitation: (
|
||||
params: CreateElicitationRequest,
|
||||
) => Effect.Effect<CreateElicitationResponse, ACPError.Error | RequestError>
|
||||
) => Effect.Effect<CreateElicitationResponse, RequestError>
|
||||
/** Tracks an incoming request from now on and returns its `Responded`. */
|
||||
readonly responded: (requestId: JsonRpcId) => Effect.Effect<void>
|
||||
}
|
||||
@@ -65,14 +60,14 @@ export function make(app: AgentApp, stream: Stream) {
|
||||
return {
|
||||
agent,
|
||||
connection: Service.of({
|
||||
sessionUpdate: (params) => ACPPromise.promise(() => agent.client.notify(methods.client.session.update, params)),
|
||||
sessionUpdate: (params) => promise(() => agent.client.notify(methods.client.session.update, params)),
|
||||
requestPermission: (params) =>
|
||||
ACPPromise.promise((signal) =>
|
||||
promise((signal) =>
|
||||
agent.client.request(methods.client.session.requestPermission, params, { cancellationSignal: signal }),
|
||||
),
|
||||
extNotification: (method, params) => ACPPromise.promise(() => agent.client.notify(method, params)),
|
||||
extNotification: (method, params) => promise(() => agent.client.notify(method, params)),
|
||||
createElicitation: (params) =>
|
||||
ACPPromise.promise((signal) =>
|
||||
promise((signal) =>
|
||||
agent.client.request(methods.client.elicitation.create, params, { cancellationSignal: signal }),
|
||||
),
|
||||
responded: (requestId) => {
|
||||
@@ -84,4 +79,11 @@ export function make(app: AgentApp, stream: Stream) {
|
||||
}
|
||||
}
|
||||
|
||||
// The client's rejections stay typed; any other rejection is a defect.
|
||||
function promise<A>(evaluate: (signal: AbortSignal) => Promise<A>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
|
||||
Effect.catch((cause) => (cause instanceof RequestError ? Effect.fail(cause) : Effect.die(cause))),
|
||||
)
|
||||
}
|
||||
|
||||
export * as ACPConnection from "./connection"
|
||||
@@ -24,28 +24,13 @@ export function contentBlockToParts(block: ContentBlock): PromptPart[] {
|
||||
}
|
||||
return [{ type: "text", text: block.text }]
|
||||
}
|
||||
case "image":
|
||||
if (block.data) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: `data:${block.mimeType};base64,${block.data}`,
|
||||
filename: filenameFromUri(block.uri ?? undefined) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
if (block.uri?.startsWith("data:") || block.uri?.startsWith("http://") || block.uri?.startsWith("https://")) {
|
||||
return [
|
||||
{
|
||||
type: "file",
|
||||
url: block.uri,
|
||||
filename: filenameFromUri(block.uri) ?? "image",
|
||||
mime: block.mimeType,
|
||||
},
|
||||
]
|
||||
}
|
||||
return []
|
||||
case "image": {
|
||||
const url = block.data ? `data:${block.mimeType};base64,${block.data}` : block.uri
|
||||
if (!url) return []
|
||||
const filename = filenameFromUri(block.uri ?? undefined) ?? "image"
|
||||
if (url.startsWith("data:")) return [{ type: "file", url, filename, mime: block.mimeType }]
|
||||
return [resourceLinkToPart({ uri: url, name: filename, mimeType: block.mimeType })]
|
||||
}
|
||||
case "resource_link":
|
||||
return [resourceLinkToPart(block)]
|
||||
case "resource":
|
||||
@@ -180,7 +165,10 @@ export function linkReference(name: string | undefined, uri: string): PromptPart
|
||||
|
||||
function filenameFromUri(uri: string | undefined): string | undefined {
|
||||
if (!uri || uri.startsWith("data:")) return undefined
|
||||
if (URL.canParse(uri)) return path.basename(new URL(uri).pathname) || undefined
|
||||
if (URL.canParse(uri)) {
|
||||
const url = new URL(uri)
|
||||
return path.basename((url.protocol === "zed:" && url.searchParams.get("path")) || url.pathname) || undefined
|
||||
}
|
||||
return path.basename(uri) || undefined
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { isAbsolute, join, resolve } from "node:path"
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { OpenCodeClient, PermissionRule, SessionInfo, SessionMetadata } from "@opencode/client/promise"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { ACPClient } from "./client"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
const key = "opencode.acp.additionalDirectories"
|
||||
const decodeStored = Schema.decodeUnknownOption(Schema.Array(Schema.String))
|
||||
@@ -31,7 +33,7 @@ export function grant(directories: readonly string[]) {
|
||||
}
|
||||
|
||||
/** The additional directories ACP last activated for the session, in request order. */
|
||||
export function list(session: Pick<SessionInfo, "metadata">) {
|
||||
export function list(session: Pick<Session.Info, "metadata">) {
|
||||
return [...Option.getOrElse(decodeStored(session.metadata?.[key]), () => [])]
|
||||
}
|
||||
|
||||
@@ -44,7 +46,7 @@ export function list(session: Pick<SessionInfo, "metadata">) {
|
||||
*/
|
||||
export const activate = Effect.fnUntraced(function* (
|
||||
client: OpenCodeClient,
|
||||
session: SessionInfo,
|
||||
session: Session.Info,
|
||||
directories: readonly string[],
|
||||
) {
|
||||
const previous = list(session)
|
||||
@@ -55,24 +57,24 @@ export const activate = Effect.fnUntraced(function* (
|
||||
...rules(directories),
|
||||
...current.filter((rule) => !owned.some((item) => isDeepStrictEqual(item, rule))),
|
||||
]
|
||||
const metadata: SessionMetadata = {
|
||||
const metadata: Session.Metadata = {
|
||||
...Object.fromEntries(Object.entries(session.metadata ?? {}).filter(([name]) => name !== key)),
|
||||
...(directories.length > 0 ? { [key]: [...directories] } : {}),
|
||||
}
|
||||
const permissionsChanged = !isDeepStrictEqual(permissions, current)
|
||||
const metadataChanged = !isDeepStrictEqual(previous, directories)
|
||||
if (!permissionsChanged && !metadataChanged) return
|
||||
yield* ACPPromise.promise(() =>
|
||||
client.session.update({
|
||||
yield* client.session
|
||||
.update({
|
||||
sessionID: session.id,
|
||||
...(permissionsChanged ? { permissions } : {}),
|
||||
...(metadataChanged ? { metadata } : {}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
// Tools compare the written path without following symlinks, so both spellings of a root are granted.
|
||||
function rules(directories: readonly string[]): PermissionRule[] {
|
||||
function rules(directories: readonly string[]): Permission.Rule[] {
|
||||
return [...new Set(directories.flatMap((directory) => [directory, FSUtil.resolve(directory)]))].map((directory) => ({
|
||||
action: "external_directory",
|
||||
resource: join(directory, "*"),
|
||||
|
||||
@@ -4,26 +4,28 @@ import type {
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { isFormAlreadySettledError, isFormNotFoundError, type OpenCodeClient } from "@opencode/client/promise"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import type { ACPService } from "./service"
|
||||
|
||||
/** A form as the event stream carries it, with an unbranded ID. */
|
||||
/** A form with an unbranded ID. */
|
||||
export type AskedForm = Omit<Form.Info, "id"> & { readonly id: string }
|
||||
type InputField = Exclude<Form.Field, Form.ExternalField>
|
||||
type SelectField = Form.StringField | Form.MultiselectField
|
||||
|
||||
// Form mode must not collect secrets, so only forms from flows known not to ask for credentials are elicited.
|
||||
const ElicitedKind = Schema.Struct({ kind: Schema.Literals(["question", "websearch.provider"]) })
|
||||
const Credential = /password|passphrase|secret|token|api[_-]?key|credential|private[_-]?key/i
|
||||
const QuestionKind = "question"
|
||||
const ElicitedKind = Schema.Struct({ kind: Schema.Literals([QuestionKind, "websearch.provider"]) })
|
||||
const Credential = /password|passphrase|secret|token|api[_ -]?key|credential|private[_ -]?key/i
|
||||
const ToolSource = Schema.Struct({ tool: Schema.Struct({ id: Schema.String }) })
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly form: AskedForm
|
||||
readonly form: Form.Info
|
||||
readonly requestedSchema: ElicitationSchema
|
||||
readonly clientSessionID: string
|
||||
readonly child?: { readonly id: string; readonly title?: string }
|
||||
@@ -57,26 +59,34 @@ export const reply = Effect.fn("cli.acp.elicitation.reply")(function* (input: In
|
||||
)
|
||||
})
|
||||
|
||||
export const UnshownQuestionMessage =
|
||||
"The question couldn't be shown to the user in this client. Continue without an answer: make reasonable assumptions and state them, or ask the user in your reply if you can't proceed."
|
||||
|
||||
/** Cancels a form, interrupting its session when the server can't cancel it. */
|
||||
export function cancel(client: OpenCodeClient, form: AskedForm) {
|
||||
return settle(() => client.session.form.cancel({ sessionID: form.sessionID, formID: form.id })).pipe(
|
||||
function cancel(client: OpenCodeClient, form: Form.Info, message?: string) {
|
||||
return client.session.form.cancel({ sessionID: form.sessionID, formID: form.id, message }).pipe(
|
||||
Effect.catchTag(["FormAlreadySettledError", "FormNotFoundError"], () => Effect.void),
|
||||
Effect.catch(() =>
|
||||
Effect.tryPromise(() => client.session.interrupt({ sessionID: form.sessionID })).pipe(Effect.ignore),
|
||||
Schema.decodeUnknownEffect(Session.ID)(form.sessionID).pipe(
|
||||
Effect.flatMap((sessionID) => client.session.interrupt({ sessionID })),
|
||||
Effect.ignore,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The form-mode schema for a form, or undefined when the form is cancelled instead: the client lacks form
|
||||
* elicitation, the form is not from an allowed flow or has a field that looks like a credential, or ACP can't
|
||||
* represent it faithfully. Unrepresentable forms have `external` fields, `when` conditions, a hidden required field
|
||||
* without a default, a default outside a field's options, or a free-text answer alongside options that must also
|
||||
* satisfy `required` or item bounds across both inputs. Hidden fields are not asked and answer with their default.
|
||||
* elicitation, the form is not from an allowed flow or has a field whose key, title, description, or option label
|
||||
* looks like a credential, or ACP can't represent it faithfully. Unrepresentable forms have `external` fields, `when`
|
||||
* conditions, a hidden required field without a default, a default outside a field's options, or a free-text answer
|
||||
* alongside options that must also satisfy `required` or item bounds across both inputs. Hidden fields are not asked
|
||||
* and answer with their default.
|
||||
*/
|
||||
export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabilities): ElicitationSchema | undefined {
|
||||
if (!capabilities.formElicitation) return undefined
|
||||
if (Option.isNone(Schema.decodeUnknownOption(ElicitedKind)(form.metadata))) return undefined
|
||||
if (form.fields.some((field) => Credential.test(field.key) || Credential.test(field.title ?? ""))) return undefined
|
||||
if (form.fields.some(credentialLike)) return undefined
|
||||
const fields = form.fields.filter((field): field is InputField => field.type !== "external")
|
||||
if (fields.length !== form.fields.length || fields.some((field) => field.when?.length)) return undefined
|
||||
if (fields.some((field) => field.hidden && field.required && field.default === undefined)) return undefined
|
||||
@@ -90,6 +100,16 @@ export function requestedSchema(form: AskedForm, capabilities: ACPService.Capabi
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a form that `requestedSchema` won't show. A question is cancelled with a message the question tool returns
|
||||
* to the model, so the turn continues instead of ending as interrupted; other forms are cancelled silently.
|
||||
*/
|
||||
export function cancelUnshown(client: OpenCodeClient, form: Form.Info) {
|
||||
return cancel(client, form, form.metadata?.kind === QuestionKind ? UnshownQuestionMessage : undefined).pipe(
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
}
|
||||
|
||||
/** The answer for an accepted response, or undefined when the user declined, cancelled, or sent invalid content. */
|
||||
export function answer(form: AskedForm, response: CreateElicitationResponse): Form.Answer | undefined {
|
||||
if (response.action !== "accept") return undefined
|
||||
@@ -119,21 +139,21 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
function respond(input: Input, outcome: Outcome) {
|
||||
if (outcome === "settled") return Effect.void
|
||||
if (outcome === "cancel") return cancel(input.client, input.form)
|
||||
return settle(() =>
|
||||
input.client.session.form.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome }),
|
||||
).pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
|
||||
),
|
||||
)
|
||||
return input.client.session.form
|
||||
.reply({ sessionID: input.form.sessionID, formID: input.form.id, answer: outcome })
|
||||
.pipe(
|
||||
Effect.catchTag(["FormAlreadySettledError", "FormNotFoundError"], () => Effect.void),
|
||||
Effect.catch((cause) =>
|
||||
Effect.logWarning("ACP form reply failed", cause).pipe(Effect.andThen(cancel(input.client, input.form))),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// A form already answered or cancelled elsewhere needs nothing more.
|
||||
function settle(evaluate: () => Promise<void>) {
|
||||
return Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(
|
||||
Effect.catch((cause) =>
|
||||
isFormAlreadySettledError(cause) || isFormNotFoundError(cause) ? Effect.void : Effect.fail(cause),
|
||||
),
|
||||
function credentialLike(field: Form.Field) {
|
||||
const labels =
|
||||
field.type === "string" || field.type === "multiselect" ? (field.options ?? []).map((option) => option.label) : []
|
||||
return [field.key, field.title, field.description, ...labels].some(
|
||||
(text) => text !== undefined && Credential.test(text),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -72,8 +72,6 @@ const Errors = Schema.Union([
|
||||
|
||||
export type Error = typeof Errors.Type
|
||||
|
||||
export const is = Schema.is(Errors)
|
||||
|
||||
export function toRequestError(error: Error): RequestError {
|
||||
switch (error._tag) {
|
||||
case "ACPSessionNotFoundError":
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import type { PermissionOption, ToolCallContent, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { EventSubscribeOutput, OpenCodeClient, PermissionReplyInput } from "@opencode/client/promise"
|
||||
import type { PermissionOption, ToolCallLocation } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Patch } from "@opencode/util/patch"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { applyPatch } from "diff"
|
||||
import { Cause, Effect, Option, Schema } from "effect"
|
||||
import { ACPClient } from "./client"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPPromise } from "./promise"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import { absolutePath, filePath, patchHunks, pendingToolCall, stringValue, toLocations, type ToolInput } from "./tool"
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type Tool = { readonly name: string; readonly input: ToolInput }
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type Tool = { readonly id: string; readonly name: string; readonly input: ToolInput }
|
||||
type Preview = ReturnType<typeof diff>
|
||||
|
||||
type Input = {
|
||||
readonly client: OpenCodeClient
|
||||
readonly connection: ACPConnection.Interface
|
||||
readonly event: PermissionEvent
|
||||
readonly sessionID: string
|
||||
readonly sessionID: Session.ID
|
||||
readonly clientSessionID: string
|
||||
readonly cwd: string
|
||||
readonly tool?: Tool
|
||||
readonly toolCallPrefix?: string
|
||||
readonly titlePrefix?: string
|
||||
readonly child?: ACPTranslate.ChildSession
|
||||
}
|
||||
|
||||
const options: PermissionOption[] = [
|
||||
@@ -27,6 +32,8 @@ const options: PermissionOption[] = [
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
]
|
||||
|
||||
const decodeFiles = Schema.decodeUnknownOption(Schema.Array(FileDiff.Info))
|
||||
|
||||
/**
|
||||
* Asks the client, then replies to the server. Once `cancelled` completes, the client's request is cancelled or never
|
||||
* sent, and the server gets `reject`. The server reply is uninterruptible, so a server that is alive but stuck can
|
||||
@@ -45,23 +52,28 @@ export const reply = Effect.fn("cli.acp.permission.reply")(function* (input: Inp
|
||||
|
||||
const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
const toolName = input.tool?.name ?? input.event.data.action
|
||||
const toolInput = { ...input.event.data.metadata, ...input.tool?.input }
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.cwd)
|
||||
const toolCallID = input.event.data.source?.id ?? input.event.data.id
|
||||
const toolInput = input.tool?.input ?? input.event.data.metadata ?? {}
|
||||
const previews = yield* permissionPreviews(toolName, toolInput, input.event.data.metadata, input.cwd).pipe(
|
||||
Effect.orElseSucceed((): Preview[] => []),
|
||||
)
|
||||
const toolCallID = input.tool?.id ?? input.event.data.id
|
||||
const toolCall = pendingToolCall({
|
||||
toolCallId: input.child ? `${input.child.id}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.child?.title, permissionTitle(toolName, toolInput, previews)),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
})
|
||||
const result = yield* input.connection.requestPermission({
|
||||
sessionId: input.clientSessionID,
|
||||
toolCall: {
|
||||
...pendingToolCall({
|
||||
toolCallId: input.toolCallPrefix ? `${input.toolCallPrefix}:${toolCallID}` : toolCallID,
|
||||
toolName,
|
||||
state: {
|
||||
input: toolInput,
|
||||
title: prefixedTitle(input.titlePrefix, permissionTitle(toolName, toolInput, previews)),
|
||||
},
|
||||
cwd: input.cwd,
|
||||
}),
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd),
|
||||
...toolCall,
|
||||
rawInput: input.tool ? toolCall.rawInput : undefined,
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data, input.cwd),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
...(input.child ? { _meta: ACPTranslate.childSessionMeta(input.child) } : {}),
|
||||
},
|
||||
options,
|
||||
})
|
||||
@@ -69,10 +81,10 @@ const ask = Effect.fnUntraced(function* (input: Input) {
|
||||
return selected === "once" || selected === "always" ? selected : "reject"
|
||||
})
|
||||
|
||||
function respond(input: Input, decision: PermissionReplyInput["decision"]) {
|
||||
return ACPPromise.promise(() =>
|
||||
input.client.permission.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision }),
|
||||
)
|
||||
function respond(input: Input, decision: Permission.Reply) {
|
||||
return input.client.permission
|
||||
.reply({ sessionID: input.sessionID, requestID: input.event.data.id, decision })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
}
|
||||
|
||||
function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
@@ -81,29 +93,33 @@ function prefixedTitle(prefix: string | undefined, title: string | undefined) {
|
||||
return `${prefix}: ${title}`
|
||||
}
|
||||
|
||||
const permissionPreviews = Effect.fnUntraced(function* (toolName: string, input: ToolInput, cwd: string) {
|
||||
// Core sends the edit and write previews as file diffs; a diff that no longer applies to the file gets no preview.
|
||||
// The patch tool's diffs have their common indentation trimmed for display, so its previews come from its own hunks.
|
||||
const permissionPreviews = Effect.fnUntraced(function* (
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
metadata: ToolInput | undefined,
|
||||
cwd: string,
|
||||
) {
|
||||
const tool = toolName.toLocaleLowerCase()
|
||||
if (tool === "patch" || tool === "apply_patch") return yield* patchPreviews(input, cwd)
|
||||
const file = filePath(input)
|
||||
if (!file) return []
|
||||
const path = absolutePath(file, cwd)
|
||||
if (tool === "write") {
|
||||
const content = stringValue(input.content)
|
||||
if (content === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
return [diff(path, oldText, content)]
|
||||
}
|
||||
if (tool !== "edit") return []
|
||||
const oldString = stringValue(input.oldString)
|
||||
const newString = stringValue(input.newString)
|
||||
if (oldString === undefined || newString === undefined) return []
|
||||
const oldText = yield* readText(path)
|
||||
const newText =
|
||||
input.replaceAll === true ? oldText.replaceAll(oldString, newString) : oldText.replace(oldString, newString)
|
||||
return [diff(path, oldText, newText)]
|
||||
const files = Option.getOrElse(decodeFiles(metadata?.files), () => [])
|
||||
const previews = yield* Effect.forEach(
|
||||
files,
|
||||
(file) =>
|
||||
Effect.gen(function* () {
|
||||
const path = absolutePath(file.file, cwd)
|
||||
const oldText = file.status === "added" ? null : yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
const newText = applyPatch(oldText ?? "", file.patch)
|
||||
return newText === false ? [] : [diff(path, oldText, newText)]
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return previews.flat()
|
||||
})
|
||||
|
||||
// Patch.derive throws when a hunk does not match the current file; the patch then gets no previews.
|
||||
// Patch.derive throws when a hunk does not match the current file, and a read can fail; the patch then gets no
|
||||
// previews.
|
||||
function patchPreviews(input: ToolInput, cwd: string) {
|
||||
return Effect.forEach(
|
||||
patchHunks(input),
|
||||
@@ -112,22 +128,22 @@ function patchPreviews(input: ToolInput, cwd: string) {
|
||||
const path = absolutePath(hunk.path, cwd)
|
||||
if (hunk.type === "add") {
|
||||
const newText = hunk.contents.endsWith("\n") || hunk.contents === "" ? hunk.contents : `${hunk.contents}\n`
|
||||
return diff(path, "", newText)
|
||||
return diff(path, null, newText)
|
||||
}
|
||||
const oldText = yield* readText(path)
|
||||
const oldText = yield* Effect.tryPromise(() => Bun.file(path).text())
|
||||
if (hunk.type === "delete") return diff(path, oldText, "")
|
||||
const derived = yield* Effect.try(() => Patch.derive(hunk.path, hunk.chunks, oldText))
|
||||
return diff(hunk.movePath ? absolutePath(hunk.movePath, cwd) : path, oldText, derived.content)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.orElseSucceed((): ToolCallContent[] => []))
|
||||
)
|
||||
}
|
||||
|
||||
function diff(path: string, oldText: string, newText: string): ToolCallContent {
|
||||
return { type: "diff", path, oldText, newText }
|
||||
function diff(path: string, oldText: string | null, newText: string) {
|
||||
return { type: "diff" as const, path, oldText, newText }
|
||||
}
|
||||
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<ToolCallContent>) {
|
||||
function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyArray<Preview>) {
|
||||
if (previews.length > 1) return `${previews.length} files`
|
||||
switch (toolName.toLocaleLowerCase()) {
|
||||
case "external_directory":
|
||||
@@ -144,26 +160,29 @@ function permissionTitle(toolName: string, input: ToolInput, previews: ReadonlyA
|
||||
case "write":
|
||||
case "patch":
|
||||
case "apply_patch":
|
||||
return filePath(input) ?? (previews[0]?.type === "diff" ? previews[0].path : undefined)
|
||||
return filePath(input) ?? previews[0]?.path
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Only these actions ask with path resources; an external directory asks for `<dir>/*`, located at the directory.
|
||||
// Core's wildcard matching treats only `*` and `?` as special.
|
||||
function permissionLocations(
|
||||
toolName: string,
|
||||
input: ToolInput,
|
||||
resources: ReadonlyArray<string>,
|
||||
ask: PermissionEvent["data"],
|
||||
cwd: string,
|
||||
): ToolCallLocation[] {
|
||||
const locations = toLocations(toolName, input, cwd)
|
||||
if (locations.length > 0) return locations
|
||||
return resources.filter((resource) => resource !== "*").map((path) => ({ path: absolutePath(path, cwd) }))
|
||||
if (locations.length > 0 || !PathActions.has(ask.action)) return locations
|
||||
const paths = ask.resources.flatMap((resource) => {
|
||||
const path = resource.endsWith("/*") ? resource.slice(0, -2) : resource
|
||||
return path && !/[*?]/.test(path) ? [absolutePath(path, cwd)] : []
|
||||
})
|
||||
return Array.from(new Set(paths), (path) => ({ path }))
|
||||
}
|
||||
|
||||
// A missing file previews as empty.
|
||||
function readText(path: string) {
|
||||
return Effect.tryPromise(() => Bun.file(path).text()).pipe(Effect.orElseSucceed(() => ""))
|
||||
}
|
||||
const PathActions = new Set(["read", "edit", "external_directory"])
|
||||
|
||||
export * as ACPPermission from "./permission"
|
||||
@@ -1,21 +0,0 @@
|
||||
import { RequestError } from "@agentclientprotocol/sdk"
|
||||
import { ClientError, isInvalidRequestError, isSessionNotFoundError } from "@opencode/client/promise"
|
||||
import { Effect } from "effect"
|
||||
import { ACPError } from "./error"
|
||||
|
||||
/** Runs a promise, keeping ACP failures typed. Any other rejection is a defect. */
|
||||
export const promise = <A>(evaluate: (signal: AbortSignal) => Promise<A>) =>
|
||||
Effect.tryPromise({ try: evaluate, catch: (cause) => cause }).pipe(Effect.catch(classify))
|
||||
|
||||
export function classify(cause: unknown): Effect.Effect<never, ACPError.Error | RequestError> {
|
||||
if (cause instanceof RequestError || ACPError.is(cause)) return Effect.fail(cause)
|
||||
if (cause instanceof ClientError && cause.reason === "Transport")
|
||||
return Effect.fail(new ACPError.ServerUnavailableError())
|
||||
if (isSessionNotFoundError(cause))
|
||||
return Effect.fail(new ACPError.SessionNotFoundError({ sessionId: cause.sessionID }))
|
||||
if (isInvalidRequestError(cause))
|
||||
return Effect.fail(new ACPError.InvalidRequestError({ message: cause.message, field: cause.field }))
|
||||
return Effect.die(cause)
|
||||
}
|
||||
|
||||
export * as ACPPromise from "./promise"
|
||||
@@ -1,12 +1,9 @@
|
||||
import {
|
||||
isInvalidRequestError,
|
||||
isSessionNotFoundError,
|
||||
type ModelRef,
|
||||
type OpenCodeClient,
|
||||
type SessionMessageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import type { OpenCodeClient } from "@opencode/client/effect"
|
||||
import { SessionsCursor } from "@opencode/protocol/groups/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Effect, Option, Ref, Result, Stream } from "effect"
|
||||
import { DateTime, Effect, Option, Ref, Schema, Stream } from "effect"
|
||||
import { withTimestampedFallback } from "@opencode/util/session-title-fallback"
|
||||
import type {
|
||||
AuthenticateRequest,
|
||||
@@ -39,11 +36,11 @@ import type {
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { configOptions, currentModel, DEFAULT_VARIANT_VALUE, parseModelSelection } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { ACPDirectories } from "./directories"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
import type { ACPTurn } from "./turn"
|
||||
@@ -53,7 +50,11 @@ export const AuthMethodID = "opencode-login"
|
||||
export type Failure = ACPError.Error | RequestError | ACPCatalog.Error
|
||||
|
||||
/** What the client advertised in `initialize`. */
|
||||
export type Capabilities = { readonly childSessionUpdates: boolean; readonly formElicitation: boolean }
|
||||
export type Capabilities = {
|
||||
readonly childSessionUpdates: boolean
|
||||
readonly formElicitation: boolean
|
||||
readonly compaction: boolean
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly initialize: (input: InitializeRequest) => Effect.Effect<InitializeResponse>
|
||||
@@ -109,52 +110,58 @@ export function make(input: {
|
||||
})
|
||||
|
||||
// Both selectors update the selection before switching on the server, so the echoed event diffs to no change.
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: ModelRef) {
|
||||
const selectModel = Effect.fnUntraced(function* (attached: Attached, model: Model.Ref) {
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, model }))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchModel({ sessionID: attached.id, model }))
|
||||
yield* input.client.session.switchModel({ sessionID: attached.id, model }).pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
const selectMode = Effect.fnUntraced(function* (attached: Attached, modeID: string) {
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
if (!catalog.modes.some((mode) => mode.id === modeID)) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID }))
|
||||
yield* ACPPromise.promise(() => input.client.session.switchAgent({ sessionID: attached.id, agent: modeID }))
|
||||
const mode = catalog.modes.find((item) => item.id === modeID)
|
||||
if (!mode) return yield* new ACPError.InvalidModeError({ mode: modeID })
|
||||
yield* Ref.update(attached.selection, (selection) => ({ ...selection, modeID: mode.id }))
|
||||
yield* input.client.session
|
||||
.switchAgent({ sessionID: attached.id, agent: mode.id })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
const getSession = Effect.fnUntraced(function* (sessionID: string, cwd: string) {
|
||||
const session = yield* ACPPromise.promise(() => input.client.session.get({ sessionID }))
|
||||
const getSession = Effect.fnUntraced(function* (sessionId: string, cwd: string) {
|
||||
const sessionID = yield* ACPClient.decodeSessionID(sessionId)
|
||||
const session = yield* input.client.session.get({ sessionID }).pipe(Effect.catch(ACPClient.classify))
|
||||
if (FSUtil.resolve(cwd) !== FSUtil.resolve(session.location.directory))
|
||||
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId: sessionID, cwd })
|
||||
return yield* new ACPError.SessionDirectoryMismatchError({ sessionId, cwd })
|
||||
return session
|
||||
})
|
||||
|
||||
const replay = (attached: Attached) =>
|
||||
Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
ACPPromise.promise(() =>
|
||||
cursor
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" }),
|
||||
).pipe(Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const)),
|
||||
).pipe(Stream.runForEach((message) => replayMessage(attached, message)))
|
||||
|
||||
// A message that fails to translate keeps the updates before the failure and does not stop the replay.
|
||||
const replayMessage = Effect.fnUntraced(function* (attached: Attached, message: SessionMessageInfo) {
|
||||
const updates = ACPTranslate.replayMessage(message, attached.cwd)
|
||||
while (true) {
|
||||
const next = yield* Effect.result(Effect.try(() => updates.next()))
|
||||
if (Result.isFailure(next))
|
||||
return yield* Effect.logWarning("ACP replay skipped the rest of a message", message.id, next.failure.cause)
|
||||
if (next.success.done) return
|
||||
yield* input.connection.sessionUpdate({ sessionId: attached.id, update: next.success.value })
|
||||
}
|
||||
const replay = Effect.fnUntraced(function* (attached: Attached) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
yield* Stream.paginate(undefined, (cursor: string | undefined) =>
|
||||
(cursor
|
||||
? input.client.message.list({ sessionID: attached.id, limit: 200, cursor })
|
||||
: input.client.message.list({ sessionID: attached.id, limit: 200, order: "asc" })
|
||||
).pipe(
|
||||
Effect.catch(ACPClient.classify),
|
||||
Effect.map((page) => [page.data, Option.fromNullishOr(page.cursor.next)] as const),
|
||||
),
|
||||
).pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.forEach(
|
||||
ACPTranslate.replayMessage(message, attached.cwd, capabilities),
|
||||
(update) => input.connection.sessionUpdate({ sessionId: attached.id, update }),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
initialize: Effect.fnUntraced(function* (params) {
|
||||
const elicitation = params.clientCapabilities?.elicitation
|
||||
const compaction = params.clientCapabilities?.session?.compaction
|
||||
yield* Ref.set(input.capabilities, {
|
||||
childSessionUpdates: params.clientCapabilities?._meta?.[ACPTranslate.ChildSessionUpdatesCapability] === true,
|
||||
formElicitation: elicitation?.form !== undefined && elicitation.form !== null,
|
||||
compaction: compaction !== undefined && compaction !== null,
|
||||
})
|
||||
const authMethod: AuthMethod = {
|
||||
description: "Run `opencode auth login` in the terminal",
|
||||
@@ -189,20 +196,17 @@ export function make(input: {
|
||||
// Load before creating so a catalog failure leaves no session behind. Agent and model stay unset
|
||||
// so the server resolves its defaults after plugins activate.
|
||||
yield* input.catalog.get(params.cwd)
|
||||
const created = yield* ACPPromise.promise(() =>
|
||||
input.client.session.create({
|
||||
location: { directory: params.cwd },
|
||||
...ACPDirectories.grant(directories),
|
||||
}),
|
||||
)
|
||||
const attached = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attached.id, configOptions: yield* currentOptions(attached) }
|
||||
const created = yield* input.client.session
|
||||
.create({ location: { directory: AbsolutePath.make(params.cwd) }, ...ACPDirectories.grant(directories) })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
const attachment = yield* input.sessions.attach(created, params.cwd, params.mcpServers)
|
||||
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
|
||||
}),
|
||||
loadSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers)
|
||||
const attached = (yield* input.sessions.attach(session, session.location.directory, params.mcpServers)).attached
|
||||
return yield* replay(attached).pipe(
|
||||
Effect.andThen(currentOptions(attached)),
|
||||
Effect.map((configOptions) => ({ configOptions })),
|
||||
@@ -210,14 +214,14 @@ export function make(input: {
|
||||
)
|
||||
}),
|
||||
listSessions: Effect.fnUntraced(function* (params) {
|
||||
const page = yield* ACPPromise.promise(() =>
|
||||
input.client.session.list({
|
||||
...(params.cwd ? { directory: params.cwd } : {}),
|
||||
const page = yield* input.client.session
|
||||
.list({
|
||||
...(params.cwd ? { directory: AbsolutePath.make(params.cwd) } : {}),
|
||||
order: "desc",
|
||||
limit: 100,
|
||||
...(params.cursor ? { cursor: params.cursor } : {}),
|
||||
}),
|
||||
)
|
||||
...(params.cursor ? { cursor: Schema.decodeSync(SessionsCursor)(params.cursor) } : {}),
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
return {
|
||||
sessions: page.data.map((session) => {
|
||||
const additionalDirectories = ACPDirectories.list(session)
|
||||
@@ -225,20 +229,21 @@ export function make(input: {
|
||||
sessionId: session.id,
|
||||
cwd: session.location.directory,
|
||||
...(additionalDirectories.length > 0 ? { additionalDirectories } : {}),
|
||||
title: withTimestampedFallback(session),
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
title: withTimestampedFallback({
|
||||
...session,
|
||||
time: { created: DateTime.toEpochMillis(session.time.created) },
|
||||
}),
|
||||
updatedAt: DateTime.formatIso(session.time.updated),
|
||||
}
|
||||
}),
|
||||
...(page.cursor.next ? { nextCursor: page.cursor.next } : {}),
|
||||
}
|
||||
}),
|
||||
deleteSession: Effect.fnUntraced(function* (params) {
|
||||
// A malformed ID fails the server's path decode, and the session ID is the only path param.
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.remove({ sessionID: params.sessionId }).catch((error) => {
|
||||
if (isSessionNotFoundError(error) || (isInvalidRequestError(error) && error.kind === "Params")) return
|
||||
throw error
|
||||
}),
|
||||
yield* ACPClient.decodeSessionID(params.sessionId).pipe(
|
||||
Effect.flatMap((sessionID) => input.client.session.remove({ sessionID })),
|
||||
Effect.catchTag(["ACPInvalidRequestError", "SessionNotFoundError"], () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
)
|
||||
yield* input.sessions.detach(params.sessionId)
|
||||
return {}
|
||||
@@ -247,8 +252,8 @@ export function make(input: {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const session = yield* getSession(params.sessionId, params.cwd)
|
||||
yield* ACPDirectories.activate(input.client, session, directories)
|
||||
const attached = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: yield* currentOptions(attached) }
|
||||
const attachment = yield* input.sessions.attach(session, session.location.directory, params.mcpServers ?? [])
|
||||
return { configOptions: attachment.configOptions }
|
||||
}),
|
||||
closeSession: Effect.fnUntraced(function* (params) {
|
||||
yield* input.turn.close(params.sessionId)
|
||||
@@ -257,14 +262,12 @@ export function make(input: {
|
||||
}),
|
||||
forkSession: Effect.fnUntraced(function* (params) {
|
||||
const directories = yield* ACPDirectories.parse(params.cwd, params.additionalDirectories)
|
||||
const forked = yield* ACPPromise.promise(() => input.client.session.fork({ sessionID: params.sessionId }))
|
||||
const sessionID = yield* ACPClient.decodeSessionID(params.sessionId)
|
||||
const forked = yield* input.client.session.fork({ sessionID }).pipe(Effect.catch(ACPClient.classify))
|
||||
// Forks copy the source session's rules, so the request list replaces any inherited grants.
|
||||
yield* ACPDirectories.activate(input.client, forked, directories)
|
||||
const attached = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
return yield* currentOptions(attached).pipe(
|
||||
Effect.map((configOptions) => ({ sessionId: attached.id, configOptions })),
|
||||
Effect.onError(() => input.sessions.detach(attached.id)),
|
||||
)
|
||||
const attachment = yield* input.sessions.attach(forked, forked.location.directory, params.mcpServers ?? [])
|
||||
return { sessionId: attachment.attached.id, configOptions: attachment.configOptions }
|
||||
}),
|
||||
setSessionConfigOption: Effect.fnUntraced(function* (params) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
@@ -283,29 +286,29 @@ export function make(input: {
|
||||
}
|
||||
}
|
||||
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: ModelRef) {
|
||||
const requireModel = Effect.fnUntraced(function* (catalog: Catalog, modelID: string, current: Model.Ref) {
|
||||
const selected = parseModelSelection(modelID, catalog.providers)
|
||||
const model = catalog.models.find(
|
||||
(item) => item.providerID === selected.model.providerID && item.id === selected.model.modelID,
|
||||
)
|
||||
if (!model) return yield* new ACPError.InvalidModelError({ providerId: selected.model.providerID, modelId: modelID })
|
||||
if (selected.variant && !model.variants.some((variant) => variant.id === selected.variant))
|
||||
return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const selectedVariant = model.variants.find((variant) => variant.id === selected.variant)
|
||||
if (selected.variant && !selectedVariant) return yield* new ACPError.InvalidEffortError({ effort: selected.variant })
|
||||
const variant =
|
||||
selected.variant ??
|
||||
selectedVariant?.id ??
|
||||
(current.providerID === model.providerID &&
|
||||
current.id === model.id &&
|
||||
(current.variant === DEFAULT_VARIANT_VALUE || model.variants.some((variant) => variant.id === current.variant))
|
||||
? current.variant
|
||||
: undefined)
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies ModelRef
|
||||
return { providerID: model.providerID, id: model.id, variant } satisfies Model.Ref
|
||||
})
|
||||
|
||||
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: ModelRef) {
|
||||
const requireEffort = Effect.fnUntraced(function* (catalog: Catalog, effort: string, current: Model.Ref) {
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model || (effort !== DEFAULT_VARIANT_VALUE && !model.variants.some((variant) => variant.id === effort)))
|
||||
return yield* new ACPError.InvalidEffortError({ effort })
|
||||
return { ...current, variant: effort } satisfies ModelRef
|
||||
return { ...current, variant: Model.VariantID.make(effort) } satisfies Model.Ref
|
||||
})
|
||||
|
||||
export * as ACPService from "./service"
|
||||
@@ -1,15 +1,17 @@
|
||||
import { isDeepStrictEqual } from "node:util"
|
||||
import type { McpServer, RequestError } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent, SessionInfo } from "@opencode/client/promise"
|
||||
import type { McpServer, RequestError, SessionConfigOption } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import { Mcp } from "@opencode/schema/mcp"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { Context, Deferred, Effect, Exit, Queue, Ref, Scope, Stream } from "effect"
|
||||
import type { ACPCatalog, Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { availableCommands, configOptions, type Selection } from "./config-option"
|
||||
import { ACPConnection } from "./connection"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPromise } from "./promise"
|
||||
|
||||
export type Attached = {
|
||||
readonly id: string
|
||||
readonly id: Session.ID
|
||||
readonly cwd: string
|
||||
readonly selection: Ref.Ref<Selection>
|
||||
}
|
||||
@@ -18,13 +20,16 @@ export interface Interface {
|
||||
/**
|
||||
* Attaches a session in its own scope, closing any previous attachment of the same ID. Once the attaching request
|
||||
* has responded, the scope follows the cwd's catalog and pushes config option and command updates while it is
|
||||
* open. A failed attach leaves the session detached.
|
||||
* open. A failed attach leaves the session detached. Returns the session's config options as of the attach.
|
||||
*/
|
||||
readonly attach: (
|
||||
session: SessionInfo,
|
||||
session: Session.Info,
|
||||
cwd: string,
|
||||
mcpServers: readonly McpServer[],
|
||||
) => Effect.Effect<Attached, ACPError.Error | RequestError | ACPCatalog.Error>
|
||||
) => Effect.Effect<
|
||||
{ readonly attached: Attached; readonly configOptions: SessionConfigOption[] },
|
||||
ACPError.Error | RequestError | ACPCatalog.Error
|
||||
>
|
||||
/** Closes the session scope. No-op when the session is not attached. */
|
||||
readonly detach: (sessionID: string) => Effect.Effect<void>
|
||||
readonly require: (sessionID: string) => Effect.Effect<Attached, ACPError.SessionNotFoundError>
|
||||
@@ -55,7 +60,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const connected = yield* Deferred.make<void>()
|
||||
|
||||
// Subscribe before any attach so a switch right after `sessions.set` reaches the session.
|
||||
yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
yield* input.client.event.subscribe().pipe(
|
||||
Stream.tap((event) => (event.type === "server.connected" ? Deferred.succeed(connected, undefined) : Effect.void)),
|
||||
Stream.filter(
|
||||
(event): event is SelectedEvent =>
|
||||
@@ -104,9 +109,8 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const key = `${server.name}:${stableStringify(config)}`
|
||||
if (registered.has(key)) return Effect.void
|
||||
registered.add(key)
|
||||
return ACPPromise.promise(() =>
|
||||
input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }),
|
||||
).pipe(
|
||||
return input.client.mcp.add({ server: server.name, location: { directory: attached.cwd }, config }).pipe(
|
||||
Effect.catch(ACPClient.classify),
|
||||
Effect.onError(() => Effect.sync(() => registered.delete(key))),
|
||||
Effect.uninterruptible,
|
||||
)
|
||||
@@ -162,7 +166,10 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.ignore, Effect.forkIn(entry.scope))
|
||||
return entry.attached
|
||||
return {
|
||||
attached: entry.attached,
|
||||
configOptions: configOptions(current, yield* Ref.get(entry.attached.selection)),
|
||||
}
|
||||
}),
|
||||
detach: Effect.fn("cli.acp.sessions.detach")(function* (sessionID) {
|
||||
const entry = sessions.get(sessionID)
|
||||
@@ -184,18 +191,18 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
function mcpConfig(server: McpServer) {
|
||||
if ("type" in server) {
|
||||
if (server.type === "acp") throw new Error("MCP-over-ACP is not supported")
|
||||
return {
|
||||
type: "remote" as const,
|
||||
return new Mcp.RemoteConfig({
|
||||
type: "remote",
|
||||
url: server.url,
|
||||
headers: Object.fromEntries(server.headers.map((header) => [header.name, header.value])),
|
||||
oauth: false as const,
|
||||
}
|
||||
oauth: false,
|
||||
})
|
||||
}
|
||||
return {
|
||||
type: "local" as const,
|
||||
return new Mcp.LocalConfig({
|
||||
type: "local",
|
||||
command: [server.command, ...server.args],
|
||||
environment: Object.fromEntries(server.env.map((entry) => [entry.name, entry.value])),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function stableStringify(value: unknown): string {
|
||||
|
||||
@@ -1,32 +1,30 @@
|
||||
import type { PromptResponse, SessionUpdate } from "@agentclientprotocol/sdk"
|
||||
import type {
|
||||
EventSubscribeOutput,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionError } from "@opencode/schema/session-error"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import { ACPCompaction } from "./compaction"
|
||||
import { partsToContentChunks, type ReplayPart } from "./content"
|
||||
import { ACPError } from "./error"
|
||||
import type { ACPService } from "./service"
|
||||
import { completedToolUpdate, errorToolUpdate, pendingToolCall, runningToolUpdate, type ToolInput } from "./tool"
|
||||
|
||||
export const ChildSessionUpdatesCapability = "opencode/child-session-updates"
|
||||
export const ChildSessionUpdateMethod = "opencode/session/child_update"
|
||||
const RetryMeta = "opencode/retry"
|
||||
const CompactionMeta = "opencode/compaction"
|
||||
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: string }
|
||||
export type TurnStart = { readonly type: "input" | "compaction"; readonly id: SessionMessage.ID }
|
||||
|
||||
export type Terminal = "succeeded" | "failed" | "interrupted"
|
||||
|
||||
export type Context = {
|
||||
readonly sessionID: string
|
||||
readonly sessionID: Session.ID
|
||||
readonly cwd: string
|
||||
readonly start: TurnStart
|
||||
readonly childUpdates: boolean
|
||||
/** Whether the client advertised `session.compaction`, so compactions use the standard session updates. */
|
||||
readonly compaction: boolean
|
||||
/** A background consumer follows open children after the parent turn ends; it never writes `session/update`. */
|
||||
readonly mode: "turn" | "background"
|
||||
}
|
||||
@@ -42,7 +40,7 @@ type Tool = {
|
||||
type RetryStatus = {
|
||||
readonly attempt: number
|
||||
readonly nextRetryAt: string
|
||||
readonly error: SessionStructuredError
|
||||
readonly error: SessionError.Error
|
||||
}
|
||||
|
||||
export type ChildSession = {
|
||||
@@ -72,19 +70,19 @@ export type TurnState = {
|
||||
readonly started: boolean
|
||||
readonly tools: ReadonlyMap<string, Tool>
|
||||
readonly retries: ReadonlyMap<string, RetryStatus>
|
||||
readonly compactions: ReadonlyMap<string, string>
|
||||
readonly compactions: ACPCompaction.Tracked
|
||||
readonly children: ReadonlyMap<string, ChildSession>
|
||||
readonly openChildren: ReadonlySet<string>
|
||||
/** Forms asked of the client that the server has not yet answered or cancelled. */
|
||||
readonly forms: ReadonlySet<string>
|
||||
readonly finish?: SessionMessageAssistant["finish"]
|
||||
readonly usage?: { readonly turn: TokenUsageInfo; readonly last: TokenUsageInfo }
|
||||
readonly stepError?: SessionStructuredError
|
||||
readonly finish?: SessionMessage.Assistant["finish"]
|
||||
readonly usage?: { readonly turn: TokenUsage.Info; readonly last: TokenUsage.Info }
|
||||
readonly stepError?: SessionError.Error
|
||||
readonly executionError?: { readonly type: string; readonly message: string }
|
||||
}
|
||||
|
||||
type PermissionEvent = Extract<EventSubscribeOutput, { type: "permission.asked" }>
|
||||
type FormEvent = Extract<EventSubscribeOutput, { type: "form.created" }>
|
||||
type PermissionEvent = Extract<OpenCodeEvent, { type: "permission.asked" }>
|
||||
type FormEvent = Extract<OpenCodeEvent, { type: "form.created" }>
|
||||
|
||||
export type Output =
|
||||
| { readonly _tag: "SessionUpdate"; readonly update: SessionUpdate }
|
||||
@@ -110,18 +108,6 @@ export type Step = {
|
||||
readonly terminal?: Terminal
|
||||
}
|
||||
|
||||
type CompactionEvent = Extract<
|
||||
EventSubscribeOutput,
|
||||
{ readonly type: "session.compaction.started" | "session.compaction.ended" | "session.compaction.failed" }
|
||||
>
|
||||
|
||||
type CompactionMarker = {
|
||||
readonly status: "started" | "completed" | "failed"
|
||||
readonly messageId: string
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly error?: SessionStructuredError
|
||||
}
|
||||
|
||||
export const initial: TurnState = {
|
||||
started: false,
|
||||
tools: new Map(),
|
||||
@@ -132,7 +118,7 @@ export const initial: TurnState = {
|
||||
forms: new Set(),
|
||||
}
|
||||
|
||||
export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context): Step {
|
||||
export function step(state: TurnState, event: OpenCodeEvent, ctx: Context): Step {
|
||||
if (event.type === "session.created") {
|
||||
const parentID = event.data.parentID
|
||||
if (!parentID) return { state, outputs: [] }
|
||||
@@ -207,11 +193,20 @@ export function step(state: TurnState, event: EventSubscribeOutput, ctx: Context
|
||||
case "session.compaction.started":
|
||||
case "session.compaction.ended":
|
||||
case "session.compaction.failed": {
|
||||
const compacted = compactionMarker(event, state.compactions)
|
||||
return {
|
||||
state: { ...state, compactions: compacted.compactions },
|
||||
outputs: compacted.marker ? send(compactionUpdate(compacted.marker)) : [],
|
||||
}
|
||||
const applied = ACPCompaction.apply(
|
||||
event,
|
||||
state.compactions,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
return { state: { ...state, compactions: applied.tracked }, outputs: applied.updates.flatMap(send) }
|
||||
}
|
||||
case "session.compaction.delta": {
|
||||
const update = ACPCompaction.chunk(
|
||||
state.compactions.get(eventSessionID),
|
||||
event.data.text,
|
||||
ACPCompaction.usesStandardUpdates(ctx, child !== undefined),
|
||||
)
|
||||
return { state, outputs: update ? send(update) : [] }
|
||||
}
|
||||
case "session.text.delta":
|
||||
return {
|
||||
@@ -378,29 +373,42 @@ export function response(state: TurnState, sessionID: string, terminal: Terminal
|
||||
return { stopReason, ...(usage ? { usage } : {}), _meta: retry ? { [RetryMeta]: retry } : {} }
|
||||
}
|
||||
|
||||
/** Fails the tools a cancelled turn left open, for when the server's wind-down never reports them. */
|
||||
export function abandonTools(state: TurnState, ctx: Context): Step {
|
||||
/**
|
||||
* Fails the tools and cancels the session's compaction a cancelled turn left open, for when the server's wind-down
|
||||
* never reports them. Child compactions are left to the consumer that follows children after the turn.
|
||||
*/
|
||||
export function abandon(state: TurnState, ctx: Context): Step {
|
||||
const compaction = state.compactions.get(ctx.sessionID)
|
||||
return {
|
||||
state: { ...state, tools: new Map() },
|
||||
outputs: [...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
state: { ...state, tools: new Map(), compactions: without(state.compactions, ctx.sessionID) },
|
||||
outputs: [
|
||||
...[...state.tools.values()].flatMap((tool) =>
|
||||
route(ctx, state.children.get(tool.sessionID), {
|
||||
sessionUpdate: "tool_call_update",
|
||||
...errorToolUpdate({
|
||||
toolCallId: tool.id,
|
||||
toolName: tool.name,
|
||||
input: tool.input,
|
||||
metadata: tool.metadata,
|
||||
content: [],
|
||||
error: "Cancelled",
|
||||
cwd: ctx.cwd,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
...(compaction
|
||||
? route(ctx, undefined, ACPCompaction.abandon(compaction, ACPCompaction.usesStandardUpdates(ctx, false)))
|
||||
: []),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** Lazy, so a message that fails to translate part way still replays the updates before the failure. */
|
||||
export function* replayMessage(message: SessionMessageInfo, cwd: string): Generator<SessionUpdate> {
|
||||
/** The updates that replay a stored message. */
|
||||
export function* replayMessage(
|
||||
message: SessionMessage.Info,
|
||||
cwd: string,
|
||||
capabilities: ACPService.Capabilities,
|
||||
): Generator<SessionUpdate> {
|
||||
if (message.type === "user") {
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, content: { type: "text", text: message.text } }
|
||||
const files: ReplayPart[] = (message.files ?? []).map((file) => ({
|
||||
@@ -413,14 +421,9 @@ export function* replayMessage(message: SessionMessageInfo, cwd: string): Genera
|
||||
yield { sessionUpdate: "user_message_chunk", messageId: message.id, ...chunk }
|
||||
return
|
||||
}
|
||||
// A running compaction has no live turn on this connection to settle it, so replay only settled ones.
|
||||
if (message.type === "compaction" && message.status !== "running") {
|
||||
yield compactionUpdate({
|
||||
status: message.status,
|
||||
messageId: message.id,
|
||||
reason: message.reason,
|
||||
...(message.status === "failed" ? { error: message.error } : {}),
|
||||
})
|
||||
if (message.type === "compaction") {
|
||||
const update = ACPCompaction.replay(message, capabilities.compaction)
|
||||
if (update) yield update
|
||||
return
|
||||
}
|
||||
if (message.type !== "assistant") return
|
||||
@@ -533,7 +536,7 @@ function childEnded(
|
||||
}
|
||||
}
|
||||
|
||||
function recordStep(state: TurnState, tokens: TokenUsageInfo): TurnState {
|
||||
function recordStep(state: TurnState, tokens: TokenUsage.Info): TurnState {
|
||||
const turn = state.usage?.turn
|
||||
return {
|
||||
...state,
|
||||
@@ -557,7 +560,7 @@ function without<K, V>(map: ReadonlyMap<K, V>, key: K) {
|
||||
return next
|
||||
}
|
||||
|
||||
function sessionIDFromEvent(event: EventSubscribeOutput) {
|
||||
function sessionIDFromEvent(event: OpenCodeEvent) {
|
||||
if ("sessionID" in event.data && typeof event.data.sessionID === "string") return event.data.sessionID
|
||||
if (event.type === "form.created") return event.data.form.sessionID
|
||||
return undefined
|
||||
@@ -567,47 +570,9 @@ function toolKey(sessionID: string, id: string) {
|
||||
return `${sessionID}:${id}`
|
||||
}
|
||||
|
||||
// Message IDs follow core's compaction message projection, so live markers match replayed ones.
|
||||
function compactionMarker(event: CompactionEvent, compactions: ReadonlyMap<string, string>) {
|
||||
const sessionID = event.data.sessionID
|
||||
if (event.type === "session.compaction.started") {
|
||||
const messageId = event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id))
|
||||
const marker: CompactionMarker = { status: "started", messageId, reason: event.data.reason }
|
||||
return { marker, compactions: new Map(compactions).set(sessionID, messageId) }
|
||||
}
|
||||
const tracked = compactions.get(sessionID)
|
||||
const remaining = without(compactions, sessionID)
|
||||
if (event.type === "session.compaction.ended") {
|
||||
const marker: CompactionMarker | undefined = tracked
|
||||
? { status: "completed", messageId: tracked, reason: event.data.reason }
|
||||
: undefined
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
// Automatic compaction can fail before it starts, for example when there is nothing to compact yet.
|
||||
const marker: CompactionMarker = {
|
||||
status: "failed",
|
||||
messageId: tracked ?? event.data.inputID ?? SessionMessage.ID.fromEvent(Event.ID.make(event.id)),
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
return { marker, compactions: remaining }
|
||||
}
|
||||
|
||||
function compactionUpdate(marker: CompactionMarker): SessionUpdate {
|
||||
return { sessionUpdate: "session_info_update", _meta: { [CompactionMeta]: marker } }
|
||||
}
|
||||
|
||||
function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
const projected = { ...update }
|
||||
projected._meta = {
|
||||
...projected._meta,
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
projected._meta = { ...projected._meta, ...childSessionMeta(child) }
|
||||
if (projected.sessionUpdate === "tool_call" || projected.sessionUpdate === "tool_call_update") {
|
||||
projected.toolCallId = `${child.id}:${projected.toolCallId}`
|
||||
if (projected.title && child.title) projected.title = `${child.title}: ${projected.title}`
|
||||
@@ -615,13 +580,24 @@ function projectChildUpdate(update: SessionUpdate, child: ChildSession) {
|
||||
return projected
|
||||
}
|
||||
|
||||
function matchesStart(event: EventSubscribeOutput, start: TurnStart) {
|
||||
export function childSessionMeta(child: ChildSession) {
|
||||
return {
|
||||
"opencode/child-session": {
|
||||
id: child.id,
|
||||
parentID: child.parentID,
|
||||
depth: child.depth,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function matchesStart(event: OpenCodeEvent, start: TurnStart) {
|
||||
return event.type === "session.inbox.delivered" && event.data.inboxID === start.id
|
||||
}
|
||||
|
||||
function resolveStopReason(input: {
|
||||
readonly terminal: Terminal
|
||||
readonly finish: SessionMessageAssistant["finish"]
|
||||
readonly finish: SessionMessage.Assistant["finish"]
|
||||
readonly error?: string
|
||||
}): PromptResponse["stopReason"] {
|
||||
if (input.terminal === "interrupted" || input.error === "aborted") return "cancelled"
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { CancelNotification, PromptRequest, PromptResponse, RequestError } from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
isSessionNotFoundError,
|
||||
type CommandInfo,
|
||||
type OpenCodeClient,
|
||||
type OpenCodeEvent,
|
||||
} from "@opencode/client/promise"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode/client/effect"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import {
|
||||
@@ -25,13 +22,13 @@ import {
|
||||
import { access, constants } from "node:fs/promises"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { builtinCommands, type ACPCatalog, type Catalog } from "./catalog"
|
||||
import { ACPClient } from "./client"
|
||||
import { currentModel } from "./config-option"
|
||||
import type { ACPConnection } from "./connection"
|
||||
import { linkReference, promptContentToParts, type PromptPart } from "./content"
|
||||
import { ACPElicitation } from "./elicitation"
|
||||
import { ACPError } from "./error"
|
||||
import { ACPPermission } from "./permission"
|
||||
import { ACPPromise } from "./promise"
|
||||
import type { ACPService } from "./service"
|
||||
import type { ACPSessions, Attached } from "./sessions"
|
||||
import { ACPTranslate } from "./translate"
|
||||
@@ -66,7 +63,7 @@ type PreparedPrompt = {
|
||||
readonly files: Array<{ readonly uri: string; readonly name?: string }>
|
||||
readonly synthetic: ReadonlyArray<string>
|
||||
readonly slash?: { readonly name: string; readonly args: string }
|
||||
readonly command?: CommandInfo
|
||||
readonly command?: Command.Info
|
||||
}
|
||||
|
||||
type PermissionAsk = Extract<ACPTranslate.Output, { readonly _tag: "PermissionAsk" }>
|
||||
@@ -99,10 +96,9 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const subscriptionScope = yield* Scope.fork(scope)
|
||||
const subscription: Subscription = {
|
||||
scope: subscriptionScope,
|
||||
events: yield* Stream.fromAsyncIterable(input.client.event.subscribe(), (cause) => cause).pipe(
|
||||
Stream.toQueue({ capacity: "unbounded" }),
|
||||
Scope.provide(subscriptionScope),
|
||||
),
|
||||
events: yield* input.client.event
|
||||
.subscribe()
|
||||
.pipe(Stream.toQueue({ capacity: "unbounded" }), Scope.provide(subscriptionScope)),
|
||||
asks: yield* Queue.unbounded<Effect.Effect<void, ACPError.Error | RequestError>>(),
|
||||
cancelled: yield* Deferred.make<void>(),
|
||||
forms: new Map(),
|
||||
@@ -121,7 +117,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const take = (subscription: Subscription) =>
|
||||
Queue.take(subscription.events).pipe(
|
||||
Effect.catch((error) =>
|
||||
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPPromise.classify(error),
|
||||
Cause.isDone(error) ? Effect.fail(new ACPError.ServerUnavailableError()) : ACPClient.classify(error),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -142,7 +138,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
clientSessionID: ctx.sessionID,
|
||||
cwd: ctx.cwd,
|
||||
tool: ask.tool,
|
||||
...(ask.child ? { toolCallPrefix: ask.child.id, titlePrefix: ask.child.title } : {}),
|
||||
child: ask.child,
|
||||
},
|
||||
Deferred.await(subscription.cancelled),
|
||||
)
|
||||
@@ -161,7 +157,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
return Effect.gen(function* () {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const requestedSchema = ACPElicitation.requestedSchema(output.form, capabilities)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancel(input.client, output.form)
|
||||
if (!requestedSchema) return yield* ACPElicitation.cancelUnshown(input.client, output.form)
|
||||
const settled = yield* Deferred.make<void>()
|
||||
subscription.forms.set(output.form.id, settled)
|
||||
yield* Queue.offer(
|
||||
@@ -210,48 +206,43 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const submit = Effect.fnUntraced(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const sessionID = attached.id
|
||||
if (prompt.synthetic.length > 0) {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.synthetic(
|
||||
{
|
||||
sessionID,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
},
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
yield* input.client.session
|
||||
.synthetic({
|
||||
sessionID,
|
||||
text: prompt.synthetic.join("\n\n"),
|
||||
description: "ACP embedded context",
|
||||
delivery: "steer",
|
||||
resume: false,
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
}
|
||||
if (prompt.start.type === "compaction") {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.compact({ sessionID, id: prompt.start.id }, { signal }),
|
||||
)
|
||||
yield* input.client.session.compact({ sessionID, id: prompt.start.id }).pipe(Effect.catch(ACPClient.classify))
|
||||
return
|
||||
}
|
||||
const command = prompt.command
|
||||
if (command) {
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.command(
|
||||
{ sessionID, name: command.name, text: prompt.slash?.args ?? "", files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
yield* input.client.session
|
||||
.command({
|
||||
sessionID,
|
||||
name: command.name,
|
||||
text: prompt.slash?.args ?? "",
|
||||
files: prompt.files,
|
||||
delivery: "steer",
|
||||
})
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
return
|
||||
}
|
||||
yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.prompt(
|
||||
{ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" },
|
||||
{ signal },
|
||||
),
|
||||
)
|
||||
yield* input.client.session
|
||||
.prompt({ sessionID, id: prompt.start.id, text: prompt.text, files: prompt.files, delivery: "steer" })
|
||||
.pipe(Effect.catch(ACPClient.classify))
|
||||
})
|
||||
|
||||
const interruptServer = (sessionID: string) =>
|
||||
ACPPromise.promise(() => input.client.session.interrupt({ sessionID })).pipe(Effect.ignoreCause)
|
||||
const interruptServer = (sessionID: Session.ID) =>
|
||||
input.client.session.interrupt({ sessionID }).pipe(Effect.ignoreCause)
|
||||
|
||||
// Rejects pending asks, interrupts the server once, then forwards its wind-down until the terminal event or the
|
||||
// timeout. Tools still open at the timeout are reported failed so the client never shows them running.
|
||||
// timeout. Tools and a compaction still open at the timeout are settled so the client never shows them running.
|
||||
const windDown = Effect.fnUntraced(function* (
|
||||
subscription: Subscription,
|
||||
ctx: ACPTranslate.Context,
|
||||
@@ -263,7 +254,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
if (!(yield* Ref.get(state)).started) return
|
||||
if (Option.isSome(yield* Fiber.await(events).pipe(Effect.timeoutOption(drainTimeout)))) return
|
||||
yield* Fiber.interrupt(events)
|
||||
const abandoned = ACPTranslate.abandonTools(yield* Ref.get(state), ctx)
|
||||
const abandoned = ACPTranslate.abandon(yield* Ref.get(state), ctx)
|
||||
yield* Ref.set(state, abandoned.state)
|
||||
yield* Effect.forEach(abandoned.outputs, (output) => interpret(subscription, ctx, output), { discard: true }).pipe(
|
||||
Effect.ignore,
|
||||
@@ -335,9 +326,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
const current = currentModel(catalog, yield* Ref.get(attached.selection))
|
||||
const model = catalog.models.find((item) => item.providerID === current.providerID && item.id === current.id)
|
||||
if (!model?.limit.context) return
|
||||
const info = yield* ACPPromise.promise((signal) =>
|
||||
input.client.session.get({ sessionID: attached.id }, { signal }),
|
||||
)
|
||||
const info = yield* input.client.session.get({ sessionID: attached.id }).pipe(Effect.catch(ACPClient.classify))
|
||||
yield* input.connection.sessionUpdate({
|
||||
sessionId: attached.id,
|
||||
update: {
|
||||
@@ -352,17 +341,15 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
)
|
||||
|
||||
// Forked uninterruptible: interruption reaches only `execute`, so the fiber still settles with a response.
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (
|
||||
attached: Attached,
|
||||
prompt: PreparedPrompt,
|
||||
childUpdates: boolean,
|
||||
) {
|
||||
const run = Effect.fn("cli.acp.turn.run")(function* (attached: Attached, prompt: PreparedPrompt) {
|
||||
const capabilities = yield* Ref.get(input.capabilities)
|
||||
const state = yield* Ref.make(ACPTranslate.initial)
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: attached.id,
|
||||
cwd: attached.cwd,
|
||||
start: prompt.start,
|
||||
childUpdates,
|
||||
childUpdates: capabilities.childSessionUpdates,
|
||||
compaction: capabilities.compaction,
|
||||
mode: "turn",
|
||||
}
|
||||
const exit = yield* Effect.exit(Effect.interruptible(execute(attached, prompt, ctx, state)))
|
||||
@@ -373,7 +360,9 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
prompt: Effect.fn("cli.acp.turn.prompt")(function* (params, signal) {
|
||||
const attached = yield* input.sessions.require(params.sessionId)
|
||||
const catalog = yield* input.catalog.get(attached.cwd)
|
||||
const childUpdates = (yield* Ref.get(input.capabilities)).childSessionUpdates
|
||||
if (params.prompt.some((block) => block.type === "image" && !block.data && !block.uri)) {
|
||||
return yield* new ACPError.InvalidRequestError({ message: "image content has no data or uri", field: "prompt" })
|
||||
}
|
||||
const parts = yield* Effect.forEach(promptContentToParts(params.prompt), referenceUnreadableFile, {
|
||||
concurrency: "unbounded",
|
||||
})
|
||||
@@ -388,7 +377,7 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
}),
|
||||
)
|
||||
}
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt, childUpdates), { uninterruptible: true })
|
||||
const forked = Effect.runForkWith(fiber.context)(run(attached, prompt), { uninterruptible: true })
|
||||
FiberMap.setUnsafe(turns, attached.id, forked)
|
||||
return Effect.succeed(forked)
|
||||
})
|
||||
@@ -401,10 +390,10 @@ export const make = Effect.fnUntraced(function* (input: {
|
||||
}),
|
||||
close: Effect.fn("cli.acp.turn.close")(function* (sessionID) {
|
||||
if (FiberMap.hasUnsafe(turns, sessionID)) return yield* FiberMap.remove(turns, sessionID)
|
||||
yield* ACPPromise.promise(() =>
|
||||
input.client.session.interrupt({ sessionID }).catch((error) => {
|
||||
if (!isSessionNotFoundError(error)) throw error
|
||||
}),
|
||||
yield* ACPClient.decodeSessionID(sessionID).pipe(
|
||||
Effect.flatMap((id) => input.client.session.interrupt({ sessionID: id })),
|
||||
Effect.catchTag(["ACPInvalidRequestError", "SessionNotFoundError"], () => Effect.void),
|
||||
Effect.catch(ACPClient.classify),
|
||||
)
|
||||
}),
|
||||
})
|
||||
@@ -419,7 +408,7 @@ function aborted(signal: AbortSignal) {
|
||||
})
|
||||
}
|
||||
|
||||
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: string): PreparedPrompt {
|
||||
function preparePrompt(catalog: Catalog, parts: readonly PromptPart[], messageID: SessionMessage.ID): PreparedPrompt {
|
||||
const visible = parts.filter((part) => part.type !== "text" || (!part.synthetic && !part.ignored))
|
||||
const synthetic = parts.flatMap((part) => (part.type === "text" && part.synthetic ? [part.text] : []))
|
||||
const text = visible.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
@@ -439,7 +428,7 @@ function referenceUnreadableFile(part: PromptPart) {
|
||||
)
|
||||
}
|
||||
|
||||
function turnStart(messageID: string, slash: PreparedPrompt["slash"]): ACPTranslate.TurnStart {
|
||||
function turnStart(messageID: SessionMessage.ID, slash: PreparedPrompt["slash"]): ACPTranslate.TurnStart {
|
||||
if (slash && builtinCommands.get(slash.name)?.start === "compaction") return { type: "compaction", id: messageID }
|
||||
return { type: "input", id: messageID }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { MessageTooLargeError, ndJsonStream } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { CrossSpawnSpawner } from "@opencode/util/cross-spawn-spawner"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||
import { Writable } from "node:stream"
|
||||
import { ACP } from "../../acp/agent"
|
||||
import { Commands } from "../commands"
|
||||
@@ -14,7 +15,15 @@ export default Runtime.handler(
|
||||
Effect.fn("cli.acp")(function* () {
|
||||
process.env.OPENCODE_CLIENT = "acp"
|
||||
const endpoint = yield* Standalone.start()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const client = yield* OpenCode.make({ baseUrl: endpoint.url }).pipe(
|
||||
Effect.provideServiceEffect(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.HttpClient.pipe(
|
||||
Effect.map(HttpClient.mapRequest(HttpClientRequest.setHeaders(Service.headers(endpoint) ?? {}))),
|
||||
),
|
||||
),
|
||||
Effect.provide(FetchHttpClient.layer),
|
||||
)
|
||||
const connection = yield* ACP.connect(client, ndJsonStream(Writable.toWeb(process.stdout), Bun.stdin.stream()))
|
||||
const failure = yield* Effect.raceFirst(
|
||||
Effect.promise(() => connection.closed).pipe(
|
||||
|
||||
@@ -2,30 +2,16 @@ import type { NewSessionResponse, PromptResponse } from "@agentclientprotocol/sd
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { createAcpFixture, expectOk, initialize } from "./subprocess"
|
||||
import { createAcpFixture, expectOk, initialize, toolCallStream } from "./subprocess"
|
||||
|
||||
// The first completion reads the file outside cwd; the follow-up completion ends the turn.
|
||||
function readingModel(file: () => string) {
|
||||
return (request: unknown) => {
|
||||
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
|
||||
return new Response(toolCall(file()), { headers: { "content-type": "text/event-stream" } })
|
||||
return toolCallStream("call_read", "read", { path: file() })
|
||||
}
|
||||
}
|
||||
|
||||
function toolCall(file: string) {
|
||||
const call = { index: 0, id: "call_read", type: "function", function: { name: "read", arguments: "" } }
|
||||
const chunks = [
|
||||
{ choices: [{ delta: { role: "assistant", tool_calls: [call] }, finish_reason: null }], usage: null },
|
||||
{
|
||||
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify({ path: file }) } }] } }],
|
||||
usage: null,
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
|
||||
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
|
||||
]
|
||||
return `${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`
|
||||
}
|
||||
|
||||
describe("acp additional directories subprocess", () => {
|
||||
test("tools read files in an additional directory without an external directory ask", async () => {
|
||||
const target = { file: "" }
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import type { PermissionRule } from "@opencode/client/promise"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { tmpdir } from "../fixture/tmpdir"
|
||||
import { makeSession, rpcError, startWire, type Wire } from "./wire-fixture"
|
||||
|
||||
const key = "opencode.acp.additionalDirectories"
|
||||
|
||||
const grant = (directory: string): PermissionRule => ({
|
||||
const grant = (directory: string): Permission.Rule => ({
|
||||
action: "external_directory",
|
||||
resource: path.join(directory, "*"),
|
||||
effect: "allow",
|
||||
@@ -17,9 +17,9 @@ const sharedLib = path.resolve("/shared/lib")
|
||||
const productDocs = path.resolve("/product-docs")
|
||||
const old = path.resolve("/old")
|
||||
|
||||
const userGrant: PermissionRule = { action: "external_directory", resource: "/x/**", effect: "allow" }
|
||||
const userGrant: Permission.Rule = { action: "external_directory", resource: "/x/**", effect: "allow" }
|
||||
|
||||
const other: PermissionRule[] = [
|
||||
const other: Permission.Rule[] = [
|
||||
{ action: "read", resource: "*.secret", effect: "deny" },
|
||||
{ action: "external_directory", resource: "/shared/lib/private/*", effect: "deny" },
|
||||
]
|
||||
@@ -170,7 +170,7 @@ describe("acp additional directories over the wire", () => {
|
||||
await acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_saved", additionalDirectories: [] })
|
||||
|
||||
const create = acp.server.requests.find((request) => request.method === "POST" && request.path === "/api/session")
|
||||
expect(create?.body).toEqual({ location: { directory: "/workspace" } })
|
||||
expect(create?.body).toMatchObject({ location: { directory: "/workspace" }, permissions: null, metadata: null })
|
||||
expect(acp.server.sessions.get(created.sessionId)?.permissions).toBeUndefined()
|
||||
expect(updates(acp)).toEqual([])
|
||||
expect(
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Clock, Duration, Effect, Fiber } from "effect"
|
||||
import { TestClock } from "effect/testing"
|
||||
import { it } from "../../../core/test/lib/effect"
|
||||
import { ACPCatalog } from "../../src/acp/catalog"
|
||||
import { buildAgent, planAgent, startWire, testModel, type Wire, type WireOptions } from "./wire-fixture"
|
||||
import { buildAgent, makeClient, planAgent, startWire, testModel, type Wire, type WireOptions } from "./wire-fixture"
|
||||
|
||||
describe("acp catalog service", () => {
|
||||
it.effect("coalesces reloads requested during a reload into one more load", () => {
|
||||
@@ -32,7 +32,7 @@ describe("acp catalog service", () => {
|
||||
yield* Fiber.joinAll(queued)
|
||||
|
||||
expect(reads(acp, "agent")).toBe(3)
|
||||
expect((yield* catalog.get("/workspace")).defaultModeID).toBe("plan")
|
||||
expect((yield* catalog.get("/workspace")).defaultModeID).toBe(Agent.ID.make("plan"))
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -73,7 +73,7 @@ describe("acp catalog service", () => {
|
||||
const loaded = yield* Fiber.join(loading)
|
||||
|
||||
expect(retriedAt).toBeGreaterThanOrEqual(25)
|
||||
expect(loaded.defaultModel).toEqual({ providerID: "test", id: "test-model", variant: "default" })
|
||||
expect(loaded.defaultModel).toMatchObject({ providerID: "test", id: "test-model", variant: "default" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -105,7 +105,10 @@ function withCatalog<A, E>(options: WireOptions, body: (acp: Wire) => Effect.Eff
|
||||
).pipe(
|
||||
Effect.flatMap((acp) =>
|
||||
body(acp).pipe(
|
||||
Effect.provideServiceEffect(ACPCatalog.Service, ACPCatalog.make(OpenCode.make({ baseUrl: acp.server.url }))),
|
||||
Effect.provideServiceEffect(
|
||||
ACPCatalog.Service,
|
||||
makeClient(acp.server.url).pipe(Effect.flatMap(ACPCatalog.make)),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { OpenCode, type OpenCodeEvent, type SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { Event } from "@opencode/schema/event"
|
||||
import type { CompactionUpdate, SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { Effect, Exit, Queue, Schema, Scope, Stream } from "effect"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
delivered,
|
||||
durableEvent,
|
||||
enqueued,
|
||||
ephemeralEvent,
|
||||
makeClient,
|
||||
interrupted,
|
||||
makeSession,
|
||||
startSession,
|
||||
startWire,
|
||||
@@ -16,11 +19,14 @@ import {
|
||||
succeeded,
|
||||
textDelta,
|
||||
turn,
|
||||
type InitializeOptions,
|
||||
type Wire,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const summary = "Summary of the earlier conversation"
|
||||
const providerError = { type: "provider.error", message: "summary request failed", status: 500 }
|
||||
const decodeCompact = Schema.decodeUnknownSync(Schema.Struct({ id: Schema.String }))
|
||||
const standard: InitializeOptions = { compaction: {} }
|
||||
|
||||
describe("acp compaction markers over the wire", () => {
|
||||
test("marks a /compact turn without forwarding the summary text", async () => {
|
||||
@@ -191,10 +197,397 @@ describe("acp compaction markers over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("acp standard compaction updates over the wire", () => {
|
||||
test("streams a /compact turn's summary and completes it with the full summary", async () => {
|
||||
const compacted = await compactTurn(
|
||||
(sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "Summary of " }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "the earlier conversation" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
],
|
||||
standard,
|
||||
)
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId: compacted.id, status: "in_progress" }),
|
||||
summaryChunk(acp.sessionId, compacted.id, "Summary of "),
|
||||
summaryChunk(acp.sessionId, compacted.id, "the earlier conversation"),
|
||||
compaction(acp.sessionId, {
|
||||
compactionId: compacted.id,
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
}),
|
||||
])
|
||||
expect(compacted.response.stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test("completes a compaction without a readable summary with a cleared summary", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: "", recent: "" }),
|
||||
textDelta(sessionID, "msg_after", "after"),
|
||||
stepEnded(sessionID, "msg_after"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "completed", summary: null }),
|
||||
chunk(acp.sessionId, "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
|
||||
test("fails a compaction with its error and clears the partial summary", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: providerError }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
summaryChunk(acp.sessionId, compactionId, "partial"),
|
||||
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: providerError.message }),
|
||||
])
|
||||
})
|
||||
|
||||
test("opens a compaction that fails before it starts before settling it", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.unavailable", message: "Nothing to compact yet" },
|
||||
}),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.failed")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "failed", summary: null, error: "Nothing to compact yet" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("reports a defect as a generic failure and keeps core's own failure messages", async () => {
|
||||
const limit = { type: "compaction.failed", message: "Compaction summary reached the output token limit" }
|
||||
const defect = { type: "compaction.failed", message: "Error: database is locked\n at run (compaction.ts:1:1)" }
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: limit }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.failed", { sessionID, reason: "auto", error: defect }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const first = await events.messageID("session.compaction.started")
|
||||
const second = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId: first, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId: first, status: "failed", summary: null, error: limit.message }),
|
||||
compaction(acp.sessionId, { compactionId: second, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId: second, status: "failed", summary: null, error: "Compaction failed" }),
|
||||
])
|
||||
})
|
||||
|
||||
test("drops summary chunks outside an open compaction", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "early" }),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "auto", text: summary, recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "late" }),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }),
|
||||
compaction(acp.sessionId, { compactionId, status: "completed", summary: [{ type: "text", text: summary }] }),
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels a compaction the server interrupts during a cancelled turn's wind-down", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "auto", recent: "" }),
|
||||
],
|
||||
onInterrupt: ({ sessionID }) => [
|
||||
durableEvent("session.compaction.failed", {
|
||||
sessionID,
|
||||
reason: "auto",
|
||||
error: { type: "compaction.interrupted", message: "Compaction was interrupted" },
|
||||
}),
|
||||
interrupted(sessionID),
|
||||
],
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
|
||||
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels a compaction left open when the wind-down never reports it", async () => {
|
||||
const submitted: string[] = []
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
submitted.push(id)
|
||||
return [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: "partial" }),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "compaction_summary_chunk")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const compactionId = submitted[0]
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
compaction(acp.sessionId, { compactionId, status: "in_progress" }).update,
|
||||
summaryChunk(acp.sessionId, compactionId, "partial").update,
|
||||
compaction(acp.sessionId, { compactionId, status: "cancelled", summary: null }).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("closes the marker of a compaction left open when the wind-down never reports it", async () => {
|
||||
const submitted: string[] = []
|
||||
await using acp = await startSession({
|
||||
cancelDrainTimeout: "50 millis",
|
||||
onPrompt: ({ sessionID, id }) => {
|
||||
submitted.push(id)
|
||||
return [
|
||||
delivered(sessionID, id),
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hello")
|
||||
await acp.waitForUpdate((item) => item.update.sessionUpdate === "session_info_update")
|
||||
await acp.notify("session/cancel", { sessionId: acp.sessionId })
|
||||
|
||||
expect((await prompt).stopReason).toBe("cancelled")
|
||||
const messageId = submitted[0]
|
||||
expect(updatesBeforeResponse(acp)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId, reason: "manual" }).update,
|
||||
marker(acp.sessionId, {
|
||||
status: "failed",
|
||||
messageId,
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
}).update,
|
||||
])
|
||||
})
|
||||
|
||||
test("keeps child compactions projected onto the parent as markers", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: standard,
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID: "ses_child", text: summary }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const messageId = await events.messageID("session.compaction.started")
|
||||
const child = { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" }
|
||||
expect(turnUpdates(acp.updates).map((item) => item.update._meta)).toEqual([
|
||||
{ "opencode/compaction": { status: "started", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
{ "opencode/compaction": { status: "completed", messageId, reason: "auto" }, "opencode/child-session": child },
|
||||
])
|
||||
})
|
||||
|
||||
test("sends child compactions as standard updates through child session updates", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { ...standard, childSessionUpdates: true },
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Explore"),
|
||||
durableEvent("session.compaction.started", { sessionID: "ses_child", reason: "auto", recent: "" }),
|
||||
durableEvent("session.compaction.ended", {
|
||||
sessionID: "ses_child",
|
||||
reason: "auto",
|
||||
text: summary,
|
||||
recent: "",
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
),
|
||||
})
|
||||
using events = await watchEvents(acp.server.url)
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const compactionId = await events.messageID("session.compaction.started")
|
||||
const child = { "opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Explore" } }
|
||||
expect(acp.childUpdates.flatMap((item) => (item.type === "update" ? [item.update] : []))).toEqual([
|
||||
{ sessionUpdate: "compaction_update", compactionId, status: "in_progress", _meta: child },
|
||||
{
|
||||
sessionUpdate: "compaction_update",
|
||||
compactionId,
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
_meta: child,
|
||||
},
|
||||
])
|
||||
expect(turnUpdates(acp.updates)).toEqual([])
|
||||
})
|
||||
|
||||
test("replays settled compactions as terminal updates at their position on session/load", async () => {
|
||||
await using acp = await startWire()
|
||||
acp.server.sessions.set("ses_compacted", makeSession("ses_compacted"))
|
||||
acp.server.messages.set("ses_compacted", [
|
||||
...compactedHistory().slice(0, 3),
|
||||
{
|
||||
id: "msg_compaction_cancelled",
|
||||
type: "compaction",
|
||||
status: "failed",
|
||||
reason: "manual",
|
||||
error: { type: "aborted", message: "Compaction cancelled" },
|
||||
time: { created: 3 },
|
||||
},
|
||||
{
|
||||
id: "msg_compaction_native",
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
reason: "auto",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
...compactedHistory().slice(3),
|
||||
])
|
||||
await acp.initialize(standard)
|
||||
|
||||
await acp.request("session/load", { cwd: "/workspace", sessionId: "ses_compacted", mcpServers: [] })
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
{
|
||||
sessionId: "ses_compacted",
|
||||
update: {
|
||||
sessionUpdate: "user_message_chunk",
|
||||
messageId: "msg_user",
|
||||
content: { type: "text", text: "hello" },
|
||||
},
|
||||
},
|
||||
compaction("ses_compacted", {
|
||||
compactionId: "msg_compaction_failed",
|
||||
status: "failed",
|
||||
summary: null,
|
||||
error: providerError.message,
|
||||
}),
|
||||
compaction("ses_compacted", {
|
||||
compactionId: "msg_compaction",
|
||||
status: "completed",
|
||||
summary: [{ type: "text", text: summary }],
|
||||
}),
|
||||
compaction("ses_compacted", { compactionId: "msg_compaction_cancelled", status: "cancelled", summary: null }),
|
||||
compaction("ses_compacted", { compactionId: "msg_compaction_native", status: "completed", summary: null }),
|
||||
chunk("ses_compacted", "msg_after", "after"),
|
||||
])
|
||||
})
|
||||
|
||||
test("treats a null compaction capability as unsupported", async () => {
|
||||
const compacted = await compactTurn(
|
||||
(sessionID, id) => [
|
||||
durableEvent("session.compaction.started", { sessionID, reason: "manual", recent: "", inputID: id }),
|
||||
ephemeralEvent("session.compaction.delta", { sessionID, text: summary }),
|
||||
durableEvent("session.compaction.ended", { sessionID, reason: "manual", text: summary, recent: "" }),
|
||||
],
|
||||
{ compaction: null },
|
||||
)
|
||||
await using acp = compacted.acp
|
||||
|
||||
expect(turnUpdates(acp.updates)).toEqual([
|
||||
marker(acp.sessionId, { status: "started", messageId: compacted.id, reason: "manual" }),
|
||||
marker(acp.sessionId, { status: "completed", messageId: compacted.id, reason: "manual" }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
// Holds the compact response so the test can publish the turn's events while the request is in flight.
|
||||
async function compactTurn(events: (sessionID: string, id: string) => OpenCodeEvent[]) {
|
||||
async function compactTurn(
|
||||
events: (sessionID: string, id: string) => OpenCodeEventEncoded[],
|
||||
capabilities?: InitializeOptions,
|
||||
) {
|
||||
const held = Promise.withResolvers<Response>()
|
||||
const acp = await startSession({ fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined) })
|
||||
const acp = await startSession({
|
||||
capabilities,
|
||||
fetch: (request) => (request.path.endsWith("/compact") ? held.promise : undefined),
|
||||
})
|
||||
const response = acp.prompt(acp.sessionId, "/compact")
|
||||
const request = await acp.until(
|
||||
() => acp.server.requests.find((item) => item.path.endsWith("/compact")),
|
||||
@@ -202,24 +595,28 @@ async function compactTurn(events: (sessionID: string, id: string) => OpenCodeEv
|
||||
)
|
||||
const id = decodeCompact(request.body).id
|
||||
acp.server.send(...turn(acp.sessionId, id, ...events(acp.sessionId, id)))
|
||||
held.resolve(Response.json({ data: {} }))
|
||||
held.resolve(enqueued(acp.sessionId, id, "compaction", {}))
|
||||
return { acp, id, response: await response }
|
||||
}
|
||||
|
||||
// Core derives an automatic compaction's message ID from the event ID the server stamps on publish.
|
||||
async function watchEvents(url: string) {
|
||||
const controller = new AbortController()
|
||||
const stream = OpenCode.make({ baseUrl: url }).event.subscribe({ signal: controller.signal })[Symbol.asyncIterator]()
|
||||
await stream.next()
|
||||
const scope = Scope.makeUnsafe()
|
||||
const events = await Effect.runPromise(
|
||||
makeClient(url).pipe(
|
||||
Effect.flatMap((client) => client.event.subscribe().pipe(Stream.toQueue({ capacity: "unbounded" }))),
|
||||
Scope.provide(scope),
|
||||
),
|
||||
)
|
||||
await Effect.runPromise(Queue.take(events))
|
||||
return {
|
||||
async messageID(type: OpenCodeEvent["type"]) {
|
||||
async messageID(type: OpenCodeEventEncoded["type"]) {
|
||||
while (true) {
|
||||
const next = await stream.next()
|
||||
if (next.done) throw new Error(`event stream ended before ${type}`)
|
||||
if (next.value.type === type) return SessionMessage.ID.fromEvent(Event.ID.make(next.value.id))
|
||||
const event = await Effect.runPromise(Queue.take(events))
|
||||
if (event.type === type) return SessionMessage.ID.fromEvent(event.id)
|
||||
}
|
||||
},
|
||||
[Symbol.dispose]: () => controller.abort(),
|
||||
[Symbol.dispose]: () => Effect.runFork(Scope.close(scope, Exit.void)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,17 +624,42 @@ function marker(sessionId: string, value: Record<string, unknown>): SessionNotif
|
||||
return { sessionId, update: { sessionUpdate: "session_info_update", _meta: { "opencode/compaction": value } } }
|
||||
}
|
||||
|
||||
function compaction(sessionId: string, update: CompactionUpdate): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "compaction_update", ...update } }
|
||||
}
|
||||
|
||||
function summaryChunk(sessionId: string, compactionId: string, text: string): SessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: { sessionUpdate: "compaction_summary_chunk", compactionId, content: { type: "text", text } },
|
||||
}
|
||||
}
|
||||
|
||||
function chunk(sessionId: string, messageId: string, text: string): SessionNotification {
|
||||
return { sessionId, update: { sessionUpdate: "agent_message_chunk", messageId, content: { type: "text", text } } }
|
||||
}
|
||||
|
||||
const isCancelledResponse = Schema.is(
|
||||
Schema.Struct({ result: Schema.Struct({ stopReason: Schema.Literal("cancelled") }) }),
|
||||
)
|
||||
|
||||
// Session updates the client received before the cancelled prompt response.
|
||||
function updatesBeforeResponse(acp: Wire) {
|
||||
const response = acp.received.findIndex(isCancelledResponse)
|
||||
expect(response).toBeGreaterThan(-1)
|
||||
const count = acp.received
|
||||
.slice(0, response)
|
||||
.filter((message) => "method" in message && message.method === "session/update").length
|
||||
return turnUpdates(acp.updates.slice(0, count)).map((item) => item.update)
|
||||
}
|
||||
|
||||
function turnUpdates(updates: readonly SessionNotification[]) {
|
||||
return updates.filter(
|
||||
(item) => item.update.sessionUpdate !== "available_commands_update" && item.update.sessionUpdate !== "usage_update",
|
||||
)
|
||||
}
|
||||
|
||||
function compactedHistory(): SessionMessageInfo[] {
|
||||
function compactedHistory(): Array<typeof SessionMessage.Info.Encoded> {
|
||||
return [
|
||||
{ id: "msg_user", type: "user", text: "hello", time: { created: 1 } },
|
||||
{
|
||||
|
||||
@@ -45,22 +45,48 @@ describe("acp content conversion", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with http URI becomes a file part", () => {
|
||||
test("image block with only a data URL URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "data:image/png;base64,AAAA" }),
|
||||
).toEqual([{ type: "file", url: "data:image/png;base64,AAAA", filename: "image", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("image block with only an http URI becomes a markdown link", () => {
|
||||
expect(
|
||||
contentBlockToParts({
|
||||
type: "image",
|
||||
data: "",
|
||||
mimeType: "image/jpeg",
|
||||
uri: "http://example.com/assets/photo.jpg",
|
||||
uri: "https://example.com/assets/photo.jpg",
|
||||
}),
|
||||
).toEqual([
|
||||
{
|
||||
type: "file",
|
||||
url: "http://example.com/assets/photo.jpg",
|
||||
filename: "photo.jpg",
|
||||
mime: "image/jpeg",
|
||||
},
|
||||
).toEqual([{ type: "text", text: "[photo.jpg](https://example.com/assets/photo.jpg)" }])
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/" }),
|
||||
).toEqual([{ type: "text", text: "[image](https://example.com/)" }])
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "http://example.com/a.png" }),
|
||||
).toEqual([{ type: "text", text: "[a.png](http://example.com/a.png)" }])
|
||||
})
|
||||
|
||||
test("image block with only a URI of another scheme or a bare path becomes a markdown link", () => {
|
||||
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "ftp://host/a.png" })).toEqual([
|
||||
{ type: "text", text: "[a.png](ftp://host/a.png)" },
|
||||
])
|
||||
expect(contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "assets/b.png" })).toEqual([
|
||||
{ type: "text", text: "[b.png](assets/b.png)" },
|
||||
])
|
||||
})
|
||||
|
||||
test("image block with only a file URI becomes a file part", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "file:///tmp/screenshot.png" }),
|
||||
).toEqual([{ type: "file", url: "file:///tmp/screenshot.png", filename: "screenshot.png", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("image block with only a zed path becomes a file part named after the path", () => {
|
||||
expect(
|
||||
contentBlockToParts({ type: "image", data: "", mimeType: "image/png", uri: "zed://file?path=/tmp/shot.png" }),
|
||||
).toEqual([{ type: "file", url: pathToFileURL("/tmp/shot.png").href, filename: "shot.png", mime: "image/png" }])
|
||||
})
|
||||
|
||||
test("resource_link file URL becomes a file part with name and fallback mime", () => {
|
||||
|
||||
@@ -45,7 +45,7 @@ const questions = (sessionID: string, id = "frm_question", tool = "call_question
|
||||
},
|
||||
})
|
||||
|
||||
const capable = { childSessionUpdates: false, formElicitation: true }
|
||||
const capable = { childSessionUpdates: false, formElicitation: true, compaction: false }
|
||||
|
||||
const form = (
|
||||
fields: ACPElicitation.AskedForm["fields"],
|
||||
@@ -191,6 +191,8 @@ describe("acp elicitation mapping", () => {
|
||||
[{ key: "api_key", type: "string" }],
|
||||
[{ key: "q0", title: "GitHub token", type: "string" }],
|
||||
[{ key: "q0", title: "Password", type: "string", hidden: true, default: "" }],
|
||||
[{ key: "q0", title: "Setup", description: "Paste your API key", type: "string" }],
|
||||
[{ key: "q0", title: "Setup", type: "string", options: [{ value: "a", label: "Use my access token" }] }],
|
||||
]
|
||||
expect(credentials.map((fields) => ACPElicitation.requestedSchema(form(fields), capable))).toEqual(
|
||||
credentials.map(() => undefined),
|
||||
@@ -332,6 +334,7 @@ describe("acp elicitation over the wire", () => {
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toHaveLength(ids.length)
|
||||
expect(acp.server.cancelledForms.map((item) => item.formID)).toEqual(ids)
|
||||
expect(acp.server.cancelledForms.map((item) => item.message)).toEqual(ids.map(() => undefined))
|
||||
expect(acp.server.repliedForms).toEqual([])
|
||||
expect(acp.server.interrupts).toEqual([])
|
||||
})
|
||||
@@ -359,6 +362,62 @@ describe("acp elicitation over the wire", () => {
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_plugin" }])
|
||||
})
|
||||
|
||||
test("cancels a question that looks like it asks for a credential with a message for the model", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_token",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "msg_tools", id: "call_question" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Setup",
|
||||
description: "Paste your API key",
|
||||
type: "string",
|
||||
options: [{ value: "Later", label: "Later" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([
|
||||
{ sessionID: acp.sessionId, formID: "frm_token", message: ACPElicitation.UnshownQuestionMessage },
|
||||
])
|
||||
})
|
||||
|
||||
test("cancels other forms it cannot show without a message", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
ephemeralEvent("form.created", {
|
||||
form: {
|
||||
id: "frm_websearch",
|
||||
sessionID,
|
||||
title: "Web search provider",
|
||||
metadata: { kind: "websearch.provider" },
|
||||
fields: [{ key: "provider", type: "string", options: [{ value: "exa", label: "Exa" }] }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
onFormCancel: ({ sessionID }) => [succeeded(sessionID)],
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.elicitations).toEqual([])
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_websearch" }])
|
||||
})
|
||||
|
||||
test("cancelling the turn cancels its pending elicitation and the form, and never sends queued ones", async () => {
|
||||
await using acp = await startSession({
|
||||
capabilities: { elicitation: true },
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("acp error boundary over the wire", () => {
|
||||
data: { errorName: "ClientError" },
|
||||
})
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP request failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
{ message: ["ACP request failed"], cause: { _tag: "ClientError" } },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AnyRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import { Cause } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -31,6 +32,10 @@ describe("acp permissions over the wire", () => {
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_once", "shell"),
|
||||
toolCalled(sessionID, "call_once", { command: "printf hello" }),
|
||||
toolStarted(sessionID, "call_always", "read"),
|
||||
toolCalled(sessionID, "call_always", { path: "/workspace/file.ts" }),
|
||||
permissionAsked(sessionID, "perm_once", {
|
||||
action: "shell",
|
||||
metadata: { command: "printf hello" },
|
||||
@@ -101,8 +106,84 @@ describe("acp permissions over the wire", () => {
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({
|
||||
title: "/tmp/outside",
|
||||
locations: [{ path: "/tmp/outside/a.ts" }],
|
||||
rawInput: { filepath: "/tmp/outside/a.ts", parentDir: "/tmp/outside" },
|
||||
})
|
||||
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
||||
})
|
||||
|
||||
test("locates path resources without glob patterns when the ask has no tool locations", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_external", {
|
||||
action: "external_directory",
|
||||
resources: ["/tmp/outside/*", "/tmp/other/*", "/tmp/outside/*"],
|
||||
metadata: {},
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_read", {
|
||||
action: "read",
|
||||
resources: ["src/[slug].ts", "src/{a,b}.ts", "**/*.ts", "src/?.ts"],
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_search", { action: "websearch", resources: ["acp spec"] }),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.locations)).toEqual([
|
||||
[{ path: "/tmp/outside" }, { path: "/tmp/other" }],
|
||||
[{ path: path.resolve("/workspace", "src/[slug].ts") }, { path: path.resolve("/workspace", "src/{a,b}.ts") }],
|
||||
[],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks under the permission ID when ACP does not track the asking tool call", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_untracked", {
|
||||
source: { type: "tool", messageID: "msg_tools", id: "call_untracked" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_untracked"])
|
||||
expect(acp.permissions[0]?.toolCall).not.toHaveProperty("rawInput")
|
||||
})
|
||||
|
||||
test("asks about a tracked tool call with its own input", async () => {
|
||||
await using acp = await startSession({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_read", "read"),
|
||||
toolCalled(sessionID, "call_read", { path: "/workspace/file.ts" }),
|
||||
permissionAsked(sessionID, "perm_read", {
|
||||
action: "read",
|
||||
metadata: { files: ["file.ts"] },
|
||||
source: { type: "tool", messageID: "msg_read", id: "call_read" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const running = acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call_update" && item.update.status === "in_progress"
|
||||
? [item.update.rawInput]
|
||||
: [],
|
||||
)
|
||||
expect(running).toEqual([{ path: "/workspace/file.ts" }])
|
||||
expect(acp.permissions[0]?.toolCall).toMatchObject({ toolCallId: "call_read", rawInput: running[0] })
|
||||
})
|
||||
|
||||
test("routes foreground child permissions through the parent ACP session", async () => {
|
||||
@@ -113,9 +194,10 @@ describe("acp permissions over the wire", () => {
|
||||
id,
|
||||
childCreated("ses_child", sessionID, "Review code"),
|
||||
durableEvent("session.execution.started", { sessionID: "ses_child" }),
|
||||
toolStarted("ses_child", "call_child", "read"),
|
||||
toolCalled("ses_child", "call_child", { path: "/workspace/child.ts" }),
|
||||
permissionAsked("ses_child", "perm_child", {
|
||||
action: "read",
|
||||
metadata: { path: "/workspace/child.ts" },
|
||||
source: { type: "tool", messageID: "msg_child", id: "call_child" },
|
||||
}),
|
||||
succeeded("ses_child"),
|
||||
@@ -125,11 +207,17 @@ describe("acp permissions over the wire", () => {
|
||||
|
||||
await acp.prompt(acp.sessionId, "hello")
|
||||
|
||||
const childMeta = {
|
||||
"opencode/child-session": { id: "ses_child", parentID: acp.sessionId, depth: 1, title: "Review code" },
|
||||
}
|
||||
expect(acp.permissions).toHaveLength(1)
|
||||
expect(acp.permissions[0]).toMatchObject({
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts" },
|
||||
toolCall: { toolCallId: "ses_child:call_child", title: "Review code: /workspace/child.ts", _meta: childMeta },
|
||||
})
|
||||
expect(
|
||||
acp.updates.flatMap((item) => (item.update.sessionUpdate === "tool_call" ? [item.update._meta] : [])),
|
||||
).toEqual([childMeta])
|
||||
expect(acp.server.replies).toEqual([{ sessionID: "ses_child", requestID: "perm_child", decision: "once" }])
|
||||
})
|
||||
|
||||
@@ -149,10 +237,17 @@ describe("acp permissions over the wire", () => {
|
||||
)
|
||||
await acp.until(() => acp.server.replies.length === 1, "background permission reply")
|
||||
|
||||
const childMeta = {
|
||||
"opencode/child-session": { id: "ses_background", parentID: acp.sessionId, depth: 1, title: "Research" },
|
||||
}
|
||||
expect(acp.permissions).toMatchObject([
|
||||
{
|
||||
sessionId: acp.sessionId,
|
||||
toolCall: { toolCallId: "ses_background:perm_background", title: "Research: /workspace/notes.md" },
|
||||
toolCall: {
|
||||
toolCallId: "ses_background:perm_background",
|
||||
title: "Research: /workspace/notes.md",
|
||||
_meta: childMeta,
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(acp.server.replies).toEqual([
|
||||
@@ -200,7 +295,7 @@ describe("acp permissions over the wire", () => {
|
||||
expect(acp.permissions.map((request) => request.toolCall.toolCallId)).toEqual(["perm_failed", "perm_next"])
|
||||
expect(decisions(acp)).toEqual([["perm_next", "once"]])
|
||||
expect(acp.logs.map((log) => ({ message: log.message, cause: Cause.squash(log.cause) }))).toMatchObject([
|
||||
{ message: ["ACP permission reply failed"], cause: { name: "ClientError", reason: "UnexpectedStatus" } },
|
||||
{ message: ["ACP permission reply failed"], cause: { _tag: "ClientError" } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -353,27 +448,24 @@ describe("acp permissions over the wire", () => {
|
||||
})
|
||||
|
||||
describe("acp edit previews over the wire", () => {
|
||||
test("previews edits during approval", async () => {
|
||||
test("previews core's edit diff during approval", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const file = path.join(dir.path, "file.ts")
|
||||
await fs.writeFile(file, "before")
|
||||
await fs.writeFile(file, "one\r\ntwo\r\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
delivered(sessionID, id),
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "before", newString: "after" }),
|
||||
permissionAsked(sessionID, "perm_edit", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
],
|
||||
onPermissionReply: async ({ sessionID }) => {
|
||||
await fs.writeFile(file, "after")
|
||||
return [
|
||||
toolSucceeded(sessionID, "call_edit", { files: [{ file: "file.ts" }], replacements: 1 }, "edited"),
|
||||
succeeded(sessionID),
|
||||
]
|
||||
},
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_edit", "edit"),
|
||||
toolCalled(sessionID, "call_edit", { path: "file.ts", oldString: "one\ntwo", newString: "one\nthree" }),
|
||||
permissionAsked(sessionID, "perm_edit", {
|
||||
action: "edit",
|
||||
resources: ["file.ts"],
|
||||
metadata: { files: [fileDiff("file.ts", "one\r\ntwo\r\n", "one\r\nthree\r\n")] },
|
||||
source: { type: "tool", messageID: "msg_edit", id: "call_edit" },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
@@ -385,7 +477,7 @@ describe("acp edit previews over the wire", () => {
|
||||
title: "file.ts",
|
||||
kind: "edit",
|
||||
locations: [{ path: file }],
|
||||
content: [{ type: "diff", path: file, oldText: "before", newText: "after" }],
|
||||
content: [{ type: "diff", path: file, oldText: "one\r\ntwo\r\n", newText: "one\r\nthree\r\n" }],
|
||||
})
|
||||
})
|
||||
|
||||
@@ -445,6 +537,85 @@ describe("acp edit previews over the wire", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("previews added files as new and deleted files as empty", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "gone.ts"), "gone\n")
|
||||
const patchText = ["*** Begin Patch", "*** Add File: added.ts", "+one", "*** End Patch"].join("\n")
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
toolStarted(sessionID, "call_write", "write"),
|
||||
toolCalled(sessionID, "call_write", { path: "written.ts", content: "two\n" }),
|
||||
permissionAsked(sessionID, "perm_write", {
|
||||
action: "edit",
|
||||
metadata: { files: [fileDiff("written.ts", "", "two\n", "added")] },
|
||||
source: { type: "tool", messageID: "msg_write", id: "call_write" },
|
||||
}),
|
||||
toolStarted(sessionID, "call_patch", "patch"),
|
||||
toolCalled(sessionID, "call_patch", { patchText }),
|
||||
permissionAsked(sessionID, "perm_patch", {
|
||||
action: "edit",
|
||||
source: { type: "tool", messageID: "msg_patch", id: "call_patch" },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_delete", {
|
||||
action: "edit",
|
||||
metadata: { files: [fileDiff("gone.ts", "gone\n", "", "deleted")] },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([
|
||||
[{ type: "diff", path: path.join(dir.path, "written.ts"), oldText: null, newText: "two\n" }],
|
||||
[{ type: "diff", path: path.join(dir.path, "added.ts"), oldText: null, newText: "one\n" }],
|
||||
[{ type: "diff", path: path.join(dir.path, "gone.ts"), oldText: "gone\n", newText: "" }],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks without previews when core's diff no longer applies or the file cannot be read", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "file.ts"), "changed\n")
|
||||
await fs.mkdir(path.join(dir.path, "folder"))
|
||||
await using acp = await startWire({
|
||||
onPrompt: ({ sessionID, id }) =>
|
||||
turn(
|
||||
sessionID,
|
||||
id,
|
||||
permissionAsked(sessionID, "perm_stale", {
|
||||
action: "edit",
|
||||
resources: ["file.ts"],
|
||||
metadata: { files: [fileDiff("file.ts", "one\n", "two\n")] },
|
||||
}),
|
||||
permissionAsked(sessionID, "perm_folder", {
|
||||
action: "edit",
|
||||
resources: ["folder"],
|
||||
metadata: { files: [fileDiff("folder", "one\n", "two\n")] },
|
||||
}),
|
||||
),
|
||||
permission: allowOnce,
|
||||
})
|
||||
await acp.initialize()
|
||||
const session = await acp.newSession(dir.path)
|
||||
|
||||
await acp.prompt(session.sessionId, "hello")
|
||||
|
||||
expect(acp.permissions.map((request) => request.toolCall)).toMatchObject([
|
||||
{ kind: "edit", locations: [{ path: path.join(dir.path, "file.ts") }] },
|
||||
{ kind: "edit", locations: [{ path: path.join(dir.path, "folder") }] },
|
||||
])
|
||||
expect(acp.permissions.map((request) => request.toolCall.content)).toEqual([undefined, undefined])
|
||||
expect(decisions(acp)).toEqual([
|
||||
["perm_stale", "once"],
|
||||
["perm_folder", "once"],
|
||||
])
|
||||
})
|
||||
|
||||
test("asks without previews when a patch does not apply to the current file", async () => {
|
||||
await using dir = await tmpdir()
|
||||
await fs.writeFile(path.join(dir.path, "first.ts"), "changed\n")
|
||||
@@ -563,3 +734,7 @@ describe("acp edit previews over the wire", () => {
|
||||
function decisions(acp: Wire) {
|
||||
return acp.server.replies.map((reply) => [reply.requestID, reply.decision])
|
||||
}
|
||||
|
||||
function fileDiff(file: string, before: string, after: string, status: "added" | "deleted" | "modified" = "modified") {
|
||||
return { file, patch: createTwoFilesPatch(file, file, before, after), additions: 1, deletions: 1, status }
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { createAcpFixture, expectOk, initialize, newSession } from "./subprocess
|
||||
const tinyPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
|
||||
|
||||
describe("acp prompt content subprocess", () => {
|
||||
test("accepts embedded text resource image and file resource link prompt content", async () => {
|
||||
test("accepts embedded text resource, inline and remote image, and file resource link prompt content", async () => {
|
||||
await using fixture = await createAcpFixture()
|
||||
await Bun.write(path.join(fixture.home, "README.md"), "# ACP content smoke\n")
|
||||
const acp = fixture.spawn()
|
||||
@@ -41,6 +41,16 @@ describe("acp prompt content subprocess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
const remote = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [
|
||||
{ type: "text", text: "Use this remote image." },
|
||||
{ type: "image", mimeType: "image/png", data: "", uri: "https://example.com/remote.png" },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const linked = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
@@ -70,8 +80,9 @@ describe("acp prompt content subprocess", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(remote.stopReason).toBe("end_turn")
|
||||
expect(linked.stopReason).toBe("end_turn")
|
||||
expect(missing.stopReason).toBe("end_turn")
|
||||
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(4)
|
||||
expect(fixture.llm.requests.length).toBeGreaterThanOrEqual(5)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { StopReason } from "@agentclientprotocol/sdk"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { Schema } from "effect"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -142,6 +142,44 @@ describe("acp prompt turns over the wire", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("attaches local image links and references remote or unreadable ones in place", async () => {
|
||||
await using dir = await tmpdir()
|
||||
const local = pathToFileURL(path.join(dir.path, "local.png")).href
|
||||
const missing = pathToFileURL(path.join(dir.path, "missing.png")).href
|
||||
await Bun.write(path.join(dir.path, "local.png"), "png")
|
||||
await using acp = await startSession()
|
||||
|
||||
const response = await acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "compare" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: "https://example.com/remote.png" },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: local },
|
||||
{ type: "image", data: "", mimeType: "image/png", uri: missing },
|
||||
])
|
||||
|
||||
expect(response.stopReason).toBe("end_turn")
|
||||
expect(acp.server.submissions).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "prompt",
|
||||
text: `compare\n[remote.png](https://example.com/remote.png)\n[missing.png](${missing})`,
|
||||
files: [{ uri: local, name: "local.png" }],
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
test("rejects an image with neither data nor uri as invalid params", async () => {
|
||||
await using acp = await startSession()
|
||||
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.prompt(acp.sessionId, [
|
||||
{ type: "text", text: "look" },
|
||||
{ type: "image", data: "", mimeType: "image/png" },
|
||||
]),
|
||||
),
|
||||
).toMatchObject({ code: -32602, message: expect.stringContaining("image content has no data or uri") })
|
||||
expect(acp.server.submissions).toEqual([])
|
||||
})
|
||||
|
||||
test("returns turn usage and publishes current context usage with cumulative session cost", async () => {
|
||||
const assistantTokens = { input: 100, output: 40, reasoning: 7, cache: { read: 11, write: 13 } }
|
||||
await using acp = await startSession({
|
||||
@@ -188,17 +226,17 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
})
|
||||
|
||||
test.each<{ name: string; stopReason: StopReason; events: (sessionID: string) => OpenCodeEvent[] }>([
|
||||
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg"), succeeded(id)] },
|
||||
test.each<{ name: string; stopReason: StopReason; events: (sessionID: string) => OpenCodeEventEncoded[] }>([
|
||||
{ name: "a normal stop", stopReason: "end_turn", events: (id) => [stepEnded(id, "msg_1"), succeeded(id)] },
|
||||
{
|
||||
name: "a length-limited step",
|
||||
stopReason: "max_tokens",
|
||||
events: (id) => [stepEnded(id, "msg", { finish: "length" }), succeeded(id)],
|
||||
events: (id) => [stepEnded(id, "msg_1", { finish: "length" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filtered step",
|
||||
stopReason: "refusal",
|
||||
events: (id) => [stepEnded(id, "msg", { finish: "content-filter" }), succeeded(id)],
|
||||
events: (id) => [stepEnded(id, "msg_1", { finish: "content-filter" }), succeeded(id)],
|
||||
},
|
||||
{
|
||||
name: "a content-filter failure",
|
||||
@@ -445,6 +483,17 @@ describe("acp prompt turns over the wire", () => {
|
||||
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
|
||||
})
|
||||
|
||||
test("fails the prompt as server unavailable when the event stream's connection drops mid-turn", async () => {
|
||||
await using acp = await startSession(held)
|
||||
|
||||
const prompt = acp.prompt(acp.sessionId, "hold")
|
||||
await admitted(acp, acp.sessionId)
|
||||
acp.server.dropEvents()
|
||||
|
||||
expect(await rpcError(prompt)).toMatchObject({ code: -32603, data: { errorName: "ServerUnavailable" } })
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
|
||||
test("$/cancel_request on the prompt request cancels the turn like session/cancel", async () => {
|
||||
await using acp = await startSession(held)
|
||||
const controller = new AbortController()
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { PromptResponse } from "@agentclientprotocol/sdk"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Option, Schema } from "effect"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import { createAcpFixture, expectOk, initialize, newSession, toolCallStream } from "./subprocess"
|
||||
|
||||
const ChatRequest = Schema.Struct({
|
||||
messages: Schema.Array(
|
||||
Schema.Struct({ role: Schema.String, tool_call_id: Schema.optional(Schema.String), content: Schema.Unknown }),
|
||||
),
|
||||
})
|
||||
|
||||
// The first completion asks a question; the follow-up completion ends the turn.
|
||||
function askingModel(request: unknown) {
|
||||
if (JSON.stringify(request).includes('"role":"tool"')) return "done"
|
||||
return toolCallStream("call_question", "question", {
|
||||
questions: [
|
||||
{
|
||||
header: "Runtime",
|
||||
question: "Which runtime?",
|
||||
options: [
|
||||
{ label: "Bun", description: "Fast" },
|
||||
{ label: "Node", description: "Stable" },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe("acp question subprocess", () => {
|
||||
test("a question the client cannot show returns to the model and the turn continues", async () => {
|
||||
await using fixture = await createAcpFixture({ respond: askingModel })
|
||||
const acp = fixture.spawn()
|
||||
await initialize(acp)
|
||||
const session = await newSession(acp, fixture.home)
|
||||
|
||||
const result = expectOk(
|
||||
await acp.request<PromptResponse>("session/prompt", {
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: "text", text: "ask me something" }],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.stopReason).toBe("end_turn")
|
||||
const results = fixture.llm.requests
|
||||
.flatMap((request) => Option.toArray(Schema.decodeUnknownOption(ChatRequest)(request)))
|
||||
.flatMap((request) => request.messages)
|
||||
.filter((message) => message.role === "tool" && message.tool_call_id === "call_question")
|
||||
expect(results).toHaveLength(1)
|
||||
expect(String(results[0]?.content)).toContain(ACPElicitation.UnshownQuestionMessage)
|
||||
}, 60_000)
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { assistantMessage, makeSession, startWire } from "./wire-fixture"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { assistantMessage, makeSession, rpcError, startWire } from "./wire-fixture"
|
||||
|
||||
describe("acp session replay over the wire", () => {
|
||||
test("replays user, text, reasoning, and tool messages in order on session/load", async () => {
|
||||
@@ -60,12 +60,13 @@ describe("acp session replay over the wire", () => {
|
||||
expect(updates[11]?.update).toMatchObject({ toolCallId: "call_streaming", status: "pending", rawInput: {} })
|
||||
})
|
||||
|
||||
test("continues replay after one message fails to translate", async () => {
|
||||
test("fails the load and detaches when a message page does not decode", async () => {
|
||||
await using acp = await startWire({
|
||||
fetch(request) {
|
||||
if (request.path !== "/api/session/ses_replay_failure/message") return undefined
|
||||
return Response.json({
|
||||
data: [
|
||||
// An errored tool without its error does not decode.
|
||||
replayToolMessage("call_first", { status: "error", input: {}, metadata: {} }),
|
||||
replayToolMessage("call_after", {
|
||||
status: "completed",
|
||||
@@ -81,24 +82,17 @@ describe("acp session replay over the wire", () => {
|
||||
acp.server.sessions.set("ses_replay_failure", makeSession("ses_replay_failure"))
|
||||
await acp.initialize()
|
||||
|
||||
const loaded = await acp.request("session/load", {
|
||||
cwd: "/workspace",
|
||||
sessionId: "ses_replay_failure",
|
||||
mcpServers: [],
|
||||
})
|
||||
|
||||
expect(loaded.configOptions).toBeDefined()
|
||||
expect(
|
||||
acp.updates.flatMap((item) =>
|
||||
item.update.sessionUpdate === "tool_call" || item.update.sessionUpdate === "tool_call_update"
|
||||
? [[item.update.toolCallId, item.update.sessionUpdate]]
|
||||
: [],
|
||||
await rpcError(
|
||||
acp.request("session/load", { cwd: "/workspace", sessionId: "ses_replay_failure", mcpServers: [] }),
|
||||
),
|
||||
).toEqual([
|
||||
["call_first", "tool_call"],
|
||||
["call_after", "tool_call"],
|
||||
["call_after", "tool_call_update"],
|
||||
])
|
||||
).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
|
||||
expect(acp.updates.filter((item) => item.update.sessionUpdate !== "available_commands_update")).toEqual([])
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: "ses_replay_failure", configId: "mode", value: "plan" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_replay_failure" } })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -109,7 +103,7 @@ function replayToolMessage(id: string, state: Record<string, unknown>) {
|
||||
}
|
||||
}
|
||||
|
||||
function replayFixtureMessages(): SessionMessageInfo[] {
|
||||
function replayFixtureMessages(): Array<typeof SessionMessage.Info.Encoded> {
|
||||
return [
|
||||
{
|
||||
id: "msg_user",
|
||||
|
||||
@@ -205,6 +205,35 @@ describe("acp session lifecycle over the wire", () => {
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_loaded" } })
|
||||
})
|
||||
|
||||
test("fails new and resume before attaching when the catalog does not decode", async () => {
|
||||
// The client rejects a model without a name, so the catalog fails to load.
|
||||
await using acp = await startWire({
|
||||
fetch: (request) =>
|
||||
request.path === "/api/model"
|
||||
? Response.json({
|
||||
location: { directory: "/workspace", project: { id: "global", directory: "/workspace" } },
|
||||
data: [testModel, { ...secondModel, name: null }],
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
acp.server.sessions.set("ses_resumed", makeSession("ses_resumed"))
|
||||
await acp.initialize()
|
||||
const existing = new Set(acp.server.sessions.keys())
|
||||
|
||||
expect(await rpcError(acp.newSession())).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
|
||||
expect(new Set(acp.server.sessions.keys())).toEqual(existing)
|
||||
|
||||
expect(
|
||||
await rpcError(acp.request("session/resume", { cwd: "/workspace", sessionId: "ses_resumed", mcpServers: [] })),
|
||||
).toMatchObject({ code: -32603, data: { errorName: "ClientError" } })
|
||||
expect(
|
||||
await rpcError(
|
||||
acp.request("session/set_config_option", { sessionId: "ses_resumed", configId: "mode", value: "plan" }),
|
||||
),
|
||||
).toMatchObject({ code: -32602, data: { sessionId: "ses_resumed" } })
|
||||
expect(acp.updates).toEqual([])
|
||||
})
|
||||
|
||||
test("lists server-backed pages for the requested cwd", async () => {
|
||||
await using acp = await startWire()
|
||||
Array.from({ length: 101 }, (_, index) =>
|
||||
@@ -265,16 +294,12 @@ describe("acp session lifecycle over the wire", () => {
|
||||
await acp.initialize()
|
||||
const params = { cwd: "/workspace", sessionId: "never-created", mcpServers: [] }
|
||||
|
||||
expect(await rpcError(acp.request("session/load", params))).toEqual({
|
||||
code: -32602,
|
||||
message: 'Invalid params: Expected a string starting with "ses"',
|
||||
data: {},
|
||||
})
|
||||
expect(await rpcError(acp.request("session/fork", params))).toEqual({
|
||||
code: -32602,
|
||||
message: "Invalid params: Invalid session ID",
|
||||
data: { field: "sessionID" },
|
||||
})
|
||||
const invalid = { code: -32602, message: "Invalid params: Invalid session ID", data: { field: "sessionID" } }
|
||||
|
||||
expect(await rpcError(acp.request("session/load", params))).toEqual(invalid)
|
||||
expect(await rpcError(acp.request("session/resume", params))).toEqual(invalid)
|
||||
expect(await rpcError(acp.request("session/fork", params))).toEqual(invalid)
|
||||
expect(acp.server.requests.filter((request) => request.path.includes("never-created"))).toEqual([])
|
||||
expect(acp.logs).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -161,6 +161,30 @@ export function expectOk<T>(response: JsonRpcResponse<T>) {
|
||||
return response.result
|
||||
}
|
||||
|
||||
/** A streamed completion that calls one tool. */
|
||||
export function toolCallStream(id: string, name: string, input: unknown) {
|
||||
const chunks = [
|
||||
{
|
||||
choices: [
|
||||
{
|
||||
delta: { role: "assistant", tool_calls: [{ index: 0, id, type: "function", function: { name, arguments: "" } }] },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
choices: [{ delta: { tool_calls: [{ index: 0, function: { arguments: JSON.stringify(input) } }] } }],
|
||||
usage: null,
|
||||
},
|
||||
{ choices: [{ delta: {}, finish_reason: "tool_calls" }], usage: null },
|
||||
{ choices: [], usage: { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 } },
|
||||
]
|
||||
return new Response(`${chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join("")}data: [DONE]\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
function verifierConfig(llmUrl: string, skills?: string) {
|
||||
const model = {
|
||||
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { OpenCodeEvent, type OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { ACPTranslate } from "../../src/acp/translate"
|
||||
import {
|
||||
childCreated,
|
||||
@@ -16,19 +19,20 @@ import {
|
||||
toolStarted,
|
||||
} from "./wire-fixture"
|
||||
|
||||
const root = "ses_root"
|
||||
const root = Session.ID.make("ses_root")
|
||||
const ctx: ACPTranslate.Context = {
|
||||
sessionID: root,
|
||||
cwd: "/workspace",
|
||||
start: { type: "input", id: "msg_input" },
|
||||
start: { type: "input", id: SessionMessage.ID.make("msg_input") },
|
||||
childUpdates: false,
|
||||
compaction: false,
|
||||
mode: "turn",
|
||||
}
|
||||
|
||||
function run(events: ReadonlyArray<OpenCodeEvent>, context = ctx, state = ACPTranslate.initial) {
|
||||
function run(events: ReadonlyArray<OpenCodeEventEncoded>, context = ctx, state = ACPTranslate.initial) {
|
||||
return events.reduce<{ state: ACPTranslate.TurnState; outputs: ACPTranslate.Output[]; terminal?: string }>(
|
||||
(acc, event, index) => {
|
||||
const next = ACPTranslate.step(acc.state, { ...event, id: `evt_${index + 1}` }, context)
|
||||
const next = ACPTranslate.step(acc.state, decodeEvent({ ...event, id: `evt_${index + 1}` }), context)
|
||||
return {
|
||||
state: next.state,
|
||||
outputs: [...acc.outputs, ...next.outputs],
|
||||
@@ -39,7 +43,9 @@ function run(events: ReadonlyArray<OpenCodeEvent>, context = ctx, state = ACPTra
|
||||
)
|
||||
}
|
||||
|
||||
function started(...events: OpenCodeEvent[]) {
|
||||
const decodeEvent = Schema.decodeUnknownSync(OpenCodeEvent)
|
||||
|
||||
function started(...events: OpenCodeEventEncoded[]) {
|
||||
return run([delivered(root, "msg_input"), ...events])
|
||||
}
|
||||
|
||||
@@ -164,7 +170,7 @@ describe("acp turn translation", () => {
|
||||
failed("ses_child", { type: "aborted", message: "interrupted" }),
|
||||
)
|
||||
|
||||
const abandoned = ACPTranslate.abandonTools(result.state, ctx)
|
||||
const abandoned = ACPTranslate.abandon(result.state, ctx)
|
||||
|
||||
expect(abandoned.state.tools.size).toBe(0)
|
||||
expect(updates(abandoned.outputs)).toEqual([
|
||||
@@ -177,6 +183,6 @@ describe("acp turn translation", () => {
|
||||
}),
|
||||
expect.objectContaining({ toolCallId: "ses_child:call_child", status: "failed", title: "Explore: read" }),
|
||||
])
|
||||
expect(ACPTranslate.abandonTools(abandoned.state, ctx).outputs).toEqual([])
|
||||
expect(ACPTranslate.abandon(abandoned.state, ctx).outputs).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk"
|
||||
import { resolve } from "node:path"
|
||||
import { ACPElicitation } from "../../src/acp/elicitation"
|
||||
import {
|
||||
assistantMessage,
|
||||
childCreated,
|
||||
@@ -30,7 +31,7 @@ describe("acp turn events over the wire", () => {
|
||||
onPrompt: ({ sessionID, id }) => [
|
||||
textDelta(sessionID, "msg_before", "before admission"),
|
||||
delivered("ses_other", id),
|
||||
delivered(sessionID, "input_other"),
|
||||
delivered(sessionID, "msg_other"),
|
||||
textDelta(sessionID, "msg_wrong_input", "wrong input"),
|
||||
delivered(sessionID, id),
|
||||
textDelta("ses_other", "msg_other", "other session"),
|
||||
@@ -363,7 +364,9 @@ describe("acp turn events over the wire", () => {
|
||||
})
|
||||
|
||||
expect((await acp.prompt(acp.sessionId, "hello")).stopReason).toBe("end_turn")
|
||||
expect(acp.server.cancelledForms).toEqual([{ sessionID: acp.sessionId, formID: "frm_question" }])
|
||||
expect(acp.server.cancelledForms).toEqual([
|
||||
{ sessionID: acp.sessionId, formID: "frm_question", message: ACPElicitation.UnshownQuestionMessage },
|
||||
])
|
||||
expect(acp.elicitations).toEqual([])
|
||||
})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type AgentRequestParamsByMethod,
|
||||
type AgentRequestResponsesByMethod,
|
||||
type AnyMessage,
|
||||
type CompactionCapabilities,
|
||||
type ContentBlock,
|
||||
type CreateElicitationRequest,
|
||||
type CreateElicitationResponse,
|
||||
@@ -17,24 +18,32 @@ import {
|
||||
type SessionNotification,
|
||||
type WriteTextFileRequest,
|
||||
} from "@agentclientprotocol/sdk"
|
||||
import {
|
||||
OpenCode,
|
||||
type AgentInfo,
|
||||
type CommandInfo,
|
||||
type LocationRef,
|
||||
type ModelInfo,
|
||||
type ModelRef,
|
||||
type OpenCodeEvent,
|
||||
type SessionInfo,
|
||||
type SessionMessageInfo,
|
||||
type TokenUsageInfo,
|
||||
} from "@opencode/client/promise"
|
||||
import { OpenCode } from "@opencode/client/effect"
|
||||
import type { OpenCodeEventEncoded } from "@opencode/protocol/groups/event"
|
||||
import type { Agent } from "@opencode/schema/agent"
|
||||
import type { Command } from "@opencode/schema/command"
|
||||
import { Form } from "@opencode/schema/form"
|
||||
import type { Location } from "@opencode/schema/location"
|
||||
import type { Model } from "@opencode/schema/model"
|
||||
import type { Session } from "@opencode/schema/session"
|
||||
import type { SessionMessage } from "@opencode/schema/session-message"
|
||||
import type { TokenUsage } from "@opencode/schema/token-usage"
|
||||
import type { BunRequest } from "bun"
|
||||
import { Duration, Effect, Exit, Logger, Option, Schema, Scope } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { ACP } from "../../src/acp/agent"
|
||||
import { ACPTurn } from "../../src/acp/turn"
|
||||
|
||||
// The fake server sends and stores the wire form of each value.
|
||||
type OpenCodeEvent = OpenCodeEventEncoded
|
||||
type AgentInfo = typeof Agent.Info.Encoded
|
||||
type CommandInfo = typeof Command.Info.Encoded
|
||||
type LocationRef = typeof Location.PublicRef.Encoded
|
||||
type ModelInfo = typeof Model.Info.Encoded
|
||||
type ModelRef = typeof Model.Ref.Encoded
|
||||
type SessionInfo = typeof Session.Info.Encoded
|
||||
type SessionMessageInfo = typeof SessionMessage.Info.Encoded
|
||||
type TokenUsageInfo = typeof TokenUsage.Info.Encoded
|
||||
type DurableEvent = Extract<OpenCodeEvent, { durable: unknown }>
|
||||
type EphemeralEvent = Exclude<OpenCodeEvent, DurableEvent>
|
||||
type EventData<Type extends OpenCodeEvent["type"]> = Extract<OpenCodeEvent, { type: Type }>["data"]
|
||||
@@ -164,6 +173,7 @@ export type InitializeOptions = {
|
||||
readonly childSessionUpdates?: boolean
|
||||
readonly terminalAuth?: boolean
|
||||
readonly elicitation?: boolean
|
||||
readonly compaction?: CompactionCapabilities | null
|
||||
}
|
||||
|
||||
export const testModel = {
|
||||
@@ -259,12 +269,16 @@ export function tokens(value = 1): TokenUsageInfo {
|
||||
return { input: value, output: value, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
}
|
||||
|
||||
// The fake server stamps ids and sequence numbers when it sends an event.
|
||||
function durable<Version extends DurableEvent["durable"]["version"]>(version: Version) {
|
||||
return <Type extends Extract<DurableEvent, { durable: { version: Version } }>["type"]>(
|
||||
type: Type,
|
||||
data: EventData<Type>,
|
||||
) => ({ id: "", created: 0, type, durable: { aggregateID: "test", seq: 0, version }, data })
|
||||
// The fake server stamps ids and sequence numbers when it sends an event. The wire form types every version as a
|
||||
// number, so the client's decode is what rejects an event sent under the wrong version.
|
||||
function durable(version: number) {
|
||||
return <Type extends DurableEvent["type"]>(type: Type, data: EventData<Type>) => ({
|
||||
id: "",
|
||||
created: 0,
|
||||
type,
|
||||
durable: { aggregateID: "test", seq: 0, version },
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
export const durableEvent = durable(1)
|
||||
@@ -370,6 +384,7 @@ export function permissionAsked(
|
||||
id: string,
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly resources?: EventData<"permission.asked">["resources"]
|
||||
readonly metadata?: EventData<"permission.asked">["metadata"]
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
@@ -378,12 +393,21 @@ export function permissionAsked(
|
||||
id,
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
resources: input.resources ?? ["*"],
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
/** The response to a submission the server admitted to the session inbox. */
|
||||
export function enqueued(sessionID: string, id: string, type: string, payload: object) {
|
||||
return Response.json({ data: { id, sessionID, time: { created: 0 }, type, payload, delivery: "steer" } })
|
||||
}
|
||||
|
||||
export function makeClient(baseUrl: string) {
|
||||
return OpenCode.make({ baseUrl }).pipe(Effect.provide(FetchHttpClient.layer))
|
||||
}
|
||||
|
||||
export async function startWire(options: WireOptions = {}) {
|
||||
const waiters = new Set<() => void>()
|
||||
const changed = () => waiters.forEach((check) => check())
|
||||
@@ -408,10 +432,8 @@ export async function startWire(options: WireOptions = {}) {
|
||||
const logs: Array<Pick<Logger.Options<unknown>, "message" | "cause">> = []
|
||||
const agentScope = Scope.makeUnsafe()
|
||||
const agentConnection = await Effect.runPromise(
|
||||
ACP.connect(
|
||||
OpenCode.make({ baseUrl: server.url }),
|
||||
ndJsonStream(agentToClient.writable, clientToAgent.readable),
|
||||
).pipe(
|
||||
makeClient(server.url).pipe(
|
||||
Effect.flatMap((client) => ACP.connect(client, ndJsonStream(agentToClient.writable, clientToAgent.readable))),
|
||||
Scope.provide(agentScope),
|
||||
(effect) =>
|
||||
options.cancelDrainTimeout === undefined
|
||||
@@ -490,6 +512,7 @@ export async function startWire(options: WireOptions = {}) {
|
||||
clientCapabilities: {
|
||||
...(capabilities.writeTextFile ? { fs: { writeTextFile: true, readTextFile: false } } : {}),
|
||||
...(capabilities.elicitation ? { elicitation: { form: {} } } : {}),
|
||||
...(capabilities.compaction !== undefined ? { session: { compaction: capabilities.compaction } } : {}),
|
||||
_meta: {
|
||||
...(capabilities.childSessionUpdates ? { "opencode/child-session-updates": true } : {}),
|
||||
...(capabilities.terminalAuth ? { "terminal-auth": true } : {}),
|
||||
@@ -565,7 +588,7 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
const selections: Selection[] = []
|
||||
const interrupts: string[] = []
|
||||
const replies: Array<{ readonly sessionID: string; readonly requestID: string; readonly decision: string }> = []
|
||||
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string }> = []
|
||||
const cancelledForms: Array<{ readonly sessionID: string; readonly formID: string; readonly message?: string }> = []
|
||||
const repliedForms: FormReply[] = []
|
||||
const mcp: Array<{ readonly name: string; readonly directory?: string; readonly config: unknown }> = []
|
||||
const fake = {
|
||||
@@ -651,7 +674,8 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
schema: Schema.Codec<A, unknown>,
|
||||
handle: (req: BunRequest<Path>, body: A, query: Record<string, string>) => Response | Promise<Response>,
|
||||
) {
|
||||
const decode = Schema.decodeUnknownOption(Schema.fromJsonString(schema))
|
||||
// The JSON codec reads an explicit null as an absent optional field, as the server's does.
|
||||
const decode = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.toCodecJson(schema)))
|
||||
return (req: BunRequest<Path>) =>
|
||||
record(req)
|
||||
.then((recorded) => {
|
||||
@@ -745,8 +769,11 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
PATCH: body(UpdateBody, (req, input) => {
|
||||
const session = fake.sessions.get(req.params.sessionID)
|
||||
if (!session) return notFound(req.params.sessionID)
|
||||
if (input.permissions) session.permissions = [...input.permissions]
|
||||
if (input.metadata) session.metadata = input.metadata
|
||||
fake.sessions.set(session.id, {
|
||||
...session,
|
||||
...(input.permissions ? { permissions: input.permissions } : {}),
|
||||
...(input.metadata ? { metadata: input.metadata } : {}),
|
||||
})
|
||||
return noContent()
|
||||
}),
|
||||
},
|
||||
@@ -788,7 +815,7 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
fake.submissions.push({ kind: "prompt", sessionID, ...input })
|
||||
const hook = options.onPrompt ?? (() => turn(sessionID, input.id))
|
||||
await emit(hook({ sessionID, id: input.id, text: input.text, signal: req.signal }))
|
||||
return Response.json({ data: { text: input.text } })
|
||||
return enqueued(sessionID, input.id, "user", { text: input.text })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/command": {
|
||||
@@ -801,13 +828,13 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
POST: body(CompactBody, (req, input) => {
|
||||
fake.submissions.push({ kind: "compact", sessionID: req.params.sessionID, ...input })
|
||||
fake.send(...turn(req.params.sessionID, input.id))
|
||||
return Response.json({ data: {} })
|
||||
return enqueued(req.params.sessionID, input.id, "compaction", {})
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/synthetic": {
|
||||
POST: body(SyntheticBody, (req, input) => {
|
||||
fake.submissions.push({ kind: "synthetic", sessionID: req.params.sessionID, ...input })
|
||||
return Response.json({ data: {} })
|
||||
return enqueued(req.params.sessionID, "msg_synthetic", "synthetic", { text: input.text })
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/interrupt": {
|
||||
@@ -827,8 +854,8 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
}),
|
||||
},
|
||||
"/api/session/:sessionID/form/:formID": {
|
||||
DELETE: route(async (req) => {
|
||||
const form = { sessionID: req.params.sessionID, formID: req.params.formID }
|
||||
DELETE: route(async (req, query) => {
|
||||
const form = { sessionID: req.params.sessionID, formID: req.params.formID, message: query.message }
|
||||
fake.cancelledForms.push(form)
|
||||
await emit(options.onFormCancel?.(form))
|
||||
return noContent()
|
||||
@@ -858,6 +885,11 @@ function startServer(options: WireOptions, changed: () => void) {
|
||||
streams.forEach((stream) => stream.close())
|
||||
streams.clear()
|
||||
},
|
||||
/** Drops the connection of every open event stream while the server keeps answering requests. */
|
||||
dropEvents() {
|
||||
streams.forEach((stream) => stream.error())
|
||||
streams.clear()
|
||||
},
|
||||
async stop() {
|
||||
streams.forEach((stream) => stream.close())
|
||||
streams.clear()
|
||||
|
||||
@@ -15,6 +15,16 @@ describe("CLI frontend import boundaries", () => {
|
||||
expect(imports).toEqual([])
|
||||
})
|
||||
|
||||
test("keeps ACP and its tests off the promise client", async () => {
|
||||
const glob = new Bun.Glob("{src,test}/acp/**/*.ts")
|
||||
const imports: string[] = []
|
||||
for await (const file of glob.scan({ cwd: path.join(root, "packages/cli") })) {
|
||||
const source = await Bun.file(path.join(root, "packages/cli", file)).text()
|
||||
if (/["']@opencode\/client(?:\/promise(?:\/[^"']*)?|\/service)?["']/.test(source)) imports.push(file)
|
||||
}
|
||||
expect(imports).toEqual([])
|
||||
})
|
||||
|
||||
test("exposes only the intentional package entrypoints", async () => {
|
||||
const run = await import("@opencode/cli/run")
|
||||
const mini = await import("@opencode/tui/mini")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -2270,6 +2270,13 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type VcsInitInput = {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly provider?: string | undefined
|
||||
}
|
||||
export type VcsInitOutput = void
|
||||
export type VcsInitOperation<E = never> = (input?: VcsInitInput) => Effect.Effect<VcsInitOutput, E>
|
||||
|
||||
export type VcsGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type VcsGetOutput = { readonly location: Location.PublicRef; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: VcsGetInput) => Effect.Effect<VcsGetOutput, E>
|
||||
@@ -2300,6 +2307,7 @@ export type VcsDiffOutput = { readonly location: Location.PublicRef; readonly da
|
||||
export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect<VcsDiffOutput, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly init: VcsInitOperation<E>
|
||||
readonly get: VcsGetOperation<E>
|
||||
readonly base: VcsBaseOperation<E>
|
||||
readonly status: VcsStatusOperation<E>
|
||||
|
||||
@@ -242,6 +242,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
VcsInitInput,
|
||||
VcsInitOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
@@ -1481,6 +1483,13 @@ const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
refresh: EndpointWorktreeRefresh(raw),
|
||||
})
|
||||
|
||||
const EndpointVcsInit = (raw: RawClient["server.vcs"]) => (input?: VcsInitInput) =>
|
||||
preserveEffect<VcsInitOutput>()(
|
||||
raw["vcs.init"]({ query: { location: input?.["location"], provider: input?.["provider"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointVcsGet = (raw: RawClient["server.vcs"]) => (input?: VcsGetInput) =>
|
||||
preserveEffect<VcsGetOutput>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1511,6 +1520,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
|
||||
)
|
||||
|
||||
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
|
||||
init: EndpointVcsInit(raw),
|
||||
get: EndpointVcsGet(raw),
|
||||
base: EndpointVcsBase(raw),
|
||||
status: EndpointVcsStatus(raw),
|
||||
|
||||
@@ -240,6 +240,8 @@ import type {
|
||||
WorktreeRemoveOutput,
|
||||
WorktreeRefreshInput,
|
||||
WorktreeRefreshOutput,
|
||||
VcsInitInput,
|
||||
VcsInitOutput,
|
||||
VcsGetInput,
|
||||
VcsGetOutput,
|
||||
VcsBaseInput,
|
||||
@@ -2040,6 +2042,18 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
vcs: {
|
||||
init: (input?: VcsInitInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsInitOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/vcs/init`,
|
||||
query: { location: input?.["location"], provider: input?.["provider"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401, 409, 501, 503],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input?: VcsGetInput, requestOptions?: RequestOptions) =>
|
||||
request<VcsGetOutput>(
|
||||
{
|
||||
|
||||
@@ -2735,11 +2735,20 @@ export const isShellNotFoundError = (value: unknown): value is ShellNotFoundErro
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
|
||||
|
||||
export type WorktreeError = {
|
||||
readonly _tag: "WorktreeError"
|
||||
readonly name: "WorktreeError"
|
||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||
}
|
||||
export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
typeof value === "object" && value !== null && "name" in value && value["name"] === "WorktreeError"
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "WorktreeError"
|
||||
|
||||
export type VcsInitNotSupportedError = {
|
||||
readonly _tag: "VcsInitNotSupportedError"
|
||||
readonly providerID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isVcsInitNotSupportedError = (value: unknown): value is VcsInitNotSupportedError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "VcsInitNotSupportedError"
|
||||
|
||||
export type ServerInfoOutput = ServerInfo
|
||||
|
||||
@@ -6494,6 +6503,19 @@ export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: s
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
export type VcsInitInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly provider?: string | undefined
|
||||
}["location"]
|
||||
readonly provider?: {
|
||||
readonly location?: { readonly directory?: string | undefined } | undefined
|
||||
readonly provider?: string | undefined
|
||||
}["provider"]
|
||||
}
|
||||
|
||||
export type VcsInitOutput = void
|
||||
|
||||
export type VcsGetInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -174,21 +174,25 @@ test("declared errors are thrown as Error instances that keep the body", async (
|
||||
expect(error).toMatchObject({ _tag: "InvalidRequestError", kind: "integration_authorization" })
|
||||
})
|
||||
|
||||
test("declared errors with a data envelope read the nested message", async () => {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () =>
|
||||
Response.json({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } }, { status: 400 }),
|
||||
})
|
||||
const error = await client.worktree.create({ projectID: "prj_test" }).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toBe("Worktree directory unavailable")
|
||||
expect(error.name).toBe("WorktreeError")
|
||||
expect(error).toMatchObject({ name: "WorktreeError", data: { message: "Worktree directory unavailable" } })
|
||||
test("worktree errors retain the existing envelope with or without a tag", async () => {
|
||||
for (const body of [
|
||||
{ name: "WorktreeError", data: { message: "Worktree directory unavailable" } },
|
||||
{ _tag: "WorktreeError", name: "WorktreeError", data: { message: "Worktree directory unavailable" } },
|
||||
]) {
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async () => Response.json(body, { status: 400 }),
|
||||
})
|
||||
const error = await client.worktree.create({ projectID: "prj_test" }).then(
|
||||
() => undefined,
|
||||
(cause: unknown) => cause,
|
||||
)
|
||||
expect(error).toBeInstanceOf(Error)
|
||||
if (!(error instanceof Error)) throw error
|
||||
expect(error.message).toBe("Worktree directory unavailable")
|
||||
expect(error.name).toBe("WorktreeError")
|
||||
expect(error).toMatchObject(body)
|
||||
}
|
||||
})
|
||||
|
||||
test("client errors keep the reason and describe the failure in the message", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -593,10 +593,6 @@ function toolMessage(input: LLMRequest["messages"][number]) {
|
||||
}
|
||||
}
|
||||
|
||||
function text(part: ContentPart) {
|
||||
return part.type === "text" ? [part.text] : []
|
||||
}
|
||||
|
||||
function userPart(part: ContentPart): UserContent {
|
||||
if (part.type === "text") return [{ type: "text", text: part.text }]
|
||||
if (part.type === "media")
|
||||
|
||||
@@ -6,7 +6,6 @@ import path from "path"
|
||||
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
|
||||
import { Config } from "../../config.js"
|
||||
import { Watcher } from "../../filesystem/watcher.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Skill } from "../../skill.js"
|
||||
import { SkillFile } from "./skill-file.js"
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
const FOLDERS = new Set([
|
||||
"node_modules",
|
||||
"bower_components",
|
||||
".pnpm-store",
|
||||
"vendor",
|
||||
".npm",
|
||||
"dist",
|
||||
"build",
|
||||
"out",
|
||||
".next",
|
||||
"target",
|
||||
"bin",
|
||||
"obj",
|
||||
".git",
|
||||
".svn",
|
||||
".hg",
|
||||
".vscode",
|
||||
".idea",
|
||||
".turbo",
|
||||
".output",
|
||||
"desktop",
|
||||
".sst",
|
||||
".cache",
|
||||
".webkit-cache",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
"mypy_cache",
|
||||
".history",
|
||||
".gradle",
|
||||
])
|
||||
|
||||
const FILES = [
|
||||
"**/*.swp",
|
||||
"**/*.swo",
|
||||
"**/*.pyc",
|
||||
"**/.DS_Store",
|
||||
"**/Thumbs.db",
|
||||
"**/logs/**",
|
||||
"**/tmp/**",
|
||||
"**/temp/**",
|
||||
"**/*.log",
|
||||
"**/coverage/**",
|
||||
"**/.nyc_output/**",
|
||||
]
|
||||
|
||||
export const PATTERNS = [...FILES, ...FOLDERS, `**/{${Array.from(FOLDERS).join(",")}}/**`]
|
||||
|
||||
export * as Ignore from "./ignore.js"
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
|
||||
export const LLMGatewayPlugin = define({
|
||||
id: "opencode.provider.llmgateway",
|
||||
|
||||
@@ -23,7 +23,8 @@ export const Plugin = define({
|
||||
id: "opencode.vcs.git",
|
||||
effect: Effect.fn("VcsGitPlugin")(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
if (location.vcs?.type !== "git") return
|
||||
// Markerless locations need this definition to initialize their first repository.
|
||||
if (location.vcs && location.vcs.type !== "git") return
|
||||
|
||||
const processes = yield* AppProcess.Service
|
||||
const adapter = make(processes, {
|
||||
@@ -35,6 +36,13 @@ export const Plugin = define({
|
||||
editor.add({
|
||||
id: "git",
|
||||
name: "Git",
|
||||
init: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* processes.run(
|
||||
ChildProcess.make(gitExecutable, ["init"], { cwd: input.worktree, stdin: "ignore" }),
|
||||
)
|
||||
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Git initialization failed"))
|
||||
}),
|
||||
info: () => adapter.info(),
|
||||
base: () => adapter.base(),
|
||||
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
|
||||
|
||||
@@ -26,7 +26,8 @@ export const Plugin = define({
|
||||
id: "opencode.vcs.hg",
|
||||
effect: Effect.fn("VcsHgPlugin")(function* (ctx) {
|
||||
const location = yield* Location.Service
|
||||
if (location.vcs?.type !== "hg") return
|
||||
// Markerless locations need this definition to initialize their first repository.
|
||||
if (location.vcs && location.vcs.type !== "hg") return
|
||||
|
||||
const processes = yield* AppProcess.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -39,6 +40,18 @@ export const Plugin = define({
|
||||
editor.add({
|
||||
id: "hg",
|
||||
name: "Mercurial",
|
||||
init: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* processes.run(
|
||||
ChildProcess.make("hg", ["init"], {
|
||||
cwd: input.worktree,
|
||||
env: { HGPLAIN: "1" },
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
if (result.exitCode !== 0) return yield* Effect.fail(new Error("Mercurial initialization failed"))
|
||||
}),
|
||||
info: () => adapter.info(),
|
||||
branches: (input) => adapter.branches({ search: input.search, limit: input.limit }),
|
||||
status: () => adapter.status(),
|
||||
|
||||
@@ -32,7 +32,7 @@ import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionRunnerRetry } from "./runner/retry.js"
|
||||
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||
import { toSessionError } from "./to-session-error.js"
|
||||
import { contentFilterError, toSessionError } from "./to-session-error.js"
|
||||
import { SessionUsage } from "./usage.js"
|
||||
|
||||
export type Settings = {
|
||||
@@ -540,10 +540,7 @@ export const layer = Layer.effect(
|
||||
message: "Compaction summary reached the output token limit",
|
||||
})
|
||||
case "content-filter":
|
||||
return unusable({
|
||||
type: "provider.content-filter",
|
||||
message: "Compaction summary was blocked by the provider",
|
||||
})
|
||||
return unusable(contentFilterError("Compaction summary was blocked by the provider", event.reason))
|
||||
default:
|
||||
return Effect.succeed(streamed)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,6 @@ import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export { MessageNotFoundError }
|
||||
|
||||
interface BoundaryInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SessionSchema } from "../schema.js"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { contentFilterError } from "../to-session-error.js"
|
||||
import type { Tool } from "../../tool.js"
|
||||
|
||||
type Input = {
|
||||
@@ -543,15 +544,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
|
||||
}
|
||||
if (event.reason.normalized === "content-filter") {
|
||||
providerFailed = true
|
||||
yield* failAssistant({
|
||||
type: "provider.content-filter",
|
||||
message: [
|
||||
`Provider blocked the response${event.reason.category === undefined ? "" : ` (${event.reason.category})`}`,
|
||||
event.reason.explanation,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(": "),
|
||||
})
|
||||
yield* failAssistant(contentFilterError("Provider blocked the response", event.reason))
|
||||
return
|
||||
}
|
||||
return
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
BusyError,
|
||||
CompactionConflictError,
|
||||
InboxConflictError,
|
||||
MessageNotFoundError,
|
||||
NotFoundError,
|
||||
PromptConflictError,
|
||||
SyntheticConflictError,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AIError, ToolFailure } from "@opencode/ai"
|
||||
import { AIError, ToolFailure, type FinishReasonDetails } from "@opencode/ai"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { SessionError } from "@opencode/schema/session-error"
|
||||
import { Permission } from "../permission.js"
|
||||
@@ -85,6 +85,15 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
return { type: "unknown", message: cause instanceof Error ? cause.message : String(cause) }
|
||||
}
|
||||
|
||||
export function contentFilterError(summary: string, reason: FinishReasonDetails): SessionError.Error {
|
||||
return {
|
||||
type: "provider.content-filter",
|
||||
message: [reason.category === undefined ? summary : `${summary} (${reason.category})`, reason.explanation]
|
||||
.filter(Boolean)
|
||||
.join(": "),
|
||||
}
|
||||
}
|
||||
|
||||
function providerError(type: string, reason: AIError["reason"]): SessionError.Error {
|
||||
const status = reason.http?.status
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { VcsEvent } from "@opencode/schema/vcs-event"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Location } from "./location.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { State } from "./state.js"
|
||||
import { emptyPatch, MAX_TOTAL_PATCH_BYTES, PATCH_CONTEXT_LINES } from "./vcs/patch.js"
|
||||
@@ -20,6 +21,10 @@ export class DiffError extends Schema.TaggedError<DiffError>()("Vcs.DiffError",
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class InitializeError extends Schema.TaggedError<InitializeError>()("Vcs.InitializeError", {
|
||||
kind: Schema.Literals(["missing", "conflict", "unknown", "unsupported", "failed"]),
|
||||
}) {}
|
||||
|
||||
export interface DiffOptions {
|
||||
readonly context?: number
|
||||
readonly base?: string
|
||||
@@ -40,6 +45,7 @@ export interface Adapter {
|
||||
|
||||
export interface Interface extends Adapter, State.Transformable<VcsEditor> {
|
||||
readonly base: () => Effect.Effect<Base | null, DiffError>
|
||||
readonly initialize: (providerID: string) => Effect.Effect<void, InitializeError>
|
||||
}
|
||||
|
||||
interface Data {
|
||||
@@ -124,7 +130,9 @@ const layer = Layer.effect(
|
||||
const provider = selected()
|
||||
const next: Info = provider
|
||||
? {
|
||||
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })),
|
||||
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), {
|
||||
branch: {},
|
||||
})),
|
||||
provider: provider.id,
|
||||
}
|
||||
: { branch: {} }
|
||||
@@ -166,6 +174,24 @@ const layer = Layer.effect(
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
initialize: Effect.fn("Vcs.initialize")(function* (providerID: string) {
|
||||
if (!(yield* fs.isDir(location.project.directory))) return yield* new InitializeError({ kind: "missing" })
|
||||
if (yield* Project.root(fs, location.project.directory)) return yield* new InitializeError({ kind: "conflict" })
|
||||
const provider = state.get().providers.get(providerID)
|
||||
if (!provider) return yield* new InitializeError({ kind: "unknown" })
|
||||
if (!provider.init) return yield* new InitializeError({ kind: "unsupported" })
|
||||
yield* provider
|
||||
.init(scope)
|
||||
.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterrupts(cause)
|
||||
? Effect.failCause(cause).pipe(Effect.orDie)
|
||||
: Effect.logWarning("vcs initialization failed", { provider: providerID, cause }).pipe(
|
||||
Effect.andThen(Effect.fail(new InitializeError({ kind: "failed" }))),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
info: Effect.fn("Vcs.info")(function* () {
|
||||
return current.info
|
||||
}),
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Ignore } from "@opencode/core/filesystem/ignore"
|
||||
// @ts-ignore
|
||||
import { createWrapper } from "@parcel/watcher/wrapper.js"
|
||||
|
||||
test("parcel patterns ignore built-in folders at any depth", async () => {
|
||||
let ignoreGlobs: string[] = []
|
||||
const watcher = createWrapper({
|
||||
subscribe: async (
|
||||
_directory: string,
|
||||
_callback: (...args: unknown[]) => unknown,
|
||||
options: { ignoreGlobs?: string[] },
|
||||
) => {
|
||||
ignoreGlobs = options.ignoreGlobs ?? []
|
||||
},
|
||||
})
|
||||
await watcher.subscribe("/tmp/project", () => {}, { ignore: Ignore.PATTERNS })
|
||||
const patterns = ignoreGlobs.map((source) => new RegExp(source))
|
||||
|
||||
for (const path of [
|
||||
"nested/node_modules",
|
||||
"nested/node_modules/package/index.js",
|
||||
"nested/.git",
|
||||
"nested/.git/HEAD",
|
||||
"nested/dist",
|
||||
"nested/dist/index.js",
|
||||
]) {
|
||||
expect(patterns.some((pattern) => pattern.test(path))).toBe(true)
|
||||
}
|
||||
expect(patterns.some((pattern) => pattern.test("nested/src/index.ts"))).toBe(false)
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Plugin } from "@opencode/plugin"
|
||||
import type { VcsDefinition } from "@opencode/plugin/effect/vcs"
|
||||
import { PluginPromise } from "@opencode/core/plugin/promise"
|
||||
import { State } from "@opencode/core/state"
|
||||
import { Effect } from "effect"
|
||||
import { it } from "../lib/effect"
|
||||
import { host } from "./host"
|
||||
|
||||
it.live("Promise VCS initializer reaches the registered provider with its location scope", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: string[] = []
|
||||
const state = State.create({
|
||||
initial: () => new Map<string, VcsDefinition>(),
|
||||
editor: (providers) => ({
|
||||
add: (definition: VcsDefinition) => providers.set(definition.id, definition),
|
||||
default: { get: () => undefined, set: (_selection: string) => {} },
|
||||
}),
|
||||
})
|
||||
const context = host()
|
||||
const plugin = PluginPromise.fromPromise(
|
||||
Plugin.define({
|
||||
id: "init-vcs",
|
||||
async setup(ctx) {
|
||||
await ctx.vcs.transform((editor) =>
|
||||
editor.add({
|
||||
id: "custom",
|
||||
name: "Custom VCS",
|
||||
init: async (scope, { signal }) => {
|
||||
expect(signal.aborted).toBe(false)
|
||||
calls.push(scope.worktree)
|
||||
},
|
||||
info: async () => ({ branch: {} }),
|
||||
branches: async () => [],
|
||||
status: async () => [],
|
||||
diff: async () => [],
|
||||
}),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* plugin.effect(host({ vcs: { ...context.vcs, transform: state.transform, reload: state.reload } }))
|
||||
const init = state.get().get("custom")?.init
|
||||
if (!init) return yield* Effect.die("Initializer was not registered")
|
||||
yield* init({ directory: "/workspace", worktree: "/workspace", canonical: "/workspace" })
|
||||
expect(calls).toEqual(["/workspace"])
|
||||
}),
|
||||
)
|
||||
@@ -2716,6 +2716,32 @@ describe("SessionRunnerLLM", () => {
|
||||
)
|
||||
})
|
||||
|
||||
scenario("explains a compaction blocked by the provider", function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
|
||||
yield* s.runPrompt("Earlier question")
|
||||
yield* s.llm.push(
|
||||
TestLLM.complete({
|
||||
reason: {
|
||||
normalized: "content-filter",
|
||||
raw: "refusal",
|
||||
category: "cyber",
|
||||
explanation: "This request was declined because it could enable cyber harm.",
|
||||
},
|
||||
}),
|
||||
)
|
||||
const compaction = yield* s.session.compact({ sessionID })
|
||||
yield* s.resume
|
||||
|
||||
expect((yield* s.messages).find((message) => message.id === compaction.id)).toMatchObject({
|
||||
status: "failed",
|
||||
error: {
|
||||
type: "provider.content-filter",
|
||||
message:
|
||||
"Compaction summary was blocked by the provider (cyber): This request was declined because it could enable cyber harm.",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
for (const header of [false, true]) {
|
||||
scenario(`stops compaction retries through the ${header ? "provider header" : "retry hook"}`, function* (s) {
|
||||
yield* s.llm.push(TestLLM.text("Earlier answer", "history"))
|
||||
|
||||
@@ -103,6 +103,29 @@ const provider = (input: Partial<VcsDefinition> = {}) =>
|
||||
}) satisfies VcsDefinition
|
||||
|
||||
describe("Vcs", () => {
|
||||
it.live("routes initialization through the requested provider before a repository exists", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
const calls: string[] = []
|
||||
yield* vcs.transform((editor) => {
|
||||
editor.add(provider({ id: "read-only" }))
|
||||
editor.add(
|
||||
provider({
|
||||
id: "custom",
|
||||
init: (input) => Effect.sync(() => void calls.push(input.worktree)),
|
||||
}),
|
||||
)
|
||||
})
|
||||
expect(yield* vcs.initialize("missing").pipe(Effect.flip)).toMatchObject({ kind: "unknown" })
|
||||
expect(yield* vcs.initialize("read-only").pipe(Effect.flip)).toMatchObject({ kind: "unsupported" })
|
||||
expect(calls).toEqual([])
|
||||
yield* vcs.initialize("custom")
|
||||
expect(calls).toEqual([directory])
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns empty results outside version control", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/gui-extensions",
|
||||
"version": "2.0.20",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FileDiffInfo } from "@opencode/client/promise"
|
||||
import type { SessionReviewLineComment } from "@opencode/session-ui/session-review"
|
||||
import { previewSelectedLines } from "@opencode/session-ui/pierre/selection-bridge"
|
||||
import { checksum } from "@opencode/util/encode"
|
||||
import { showToast } from "@opencode/ui/toast"
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createComputed, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
@@ -46,6 +47,7 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
|
||||
scroll: undefined as HTMLDivElement | undefined,
|
||||
pendingFile: undefined as string | undefined,
|
||||
deferRender: false,
|
||||
initializingGit: false,
|
||||
// The filter is transient by design: a persisted filter would silently hide
|
||||
// files after a reload.
|
||||
filter: "",
|
||||
@@ -218,6 +220,45 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
|
||||
if (!stored()) return false
|
||||
return !diffQuery.isPending
|
||||
}
|
||||
const lifetime = { disposed: false }
|
||||
onCleanup(() => {
|
||||
lifetime.disposed = true
|
||||
})
|
||||
const initializeGit = () => {
|
||||
if (state.initializingGit) return
|
||||
const location = view.location
|
||||
if (!location || !view.server.connected) {
|
||||
showToast({ variant: "error", title: ctx.t("common.requestFailed") })
|
||||
return
|
||||
}
|
||||
const key = view.key
|
||||
const sessionID = view.id
|
||||
setState("initializingGit", true)
|
||||
void view.server.client.vcs
|
||||
.init({ location, provider: "git" })
|
||||
.then(async () => {
|
||||
if (lifetime.disposed || ctx.signal.aborted || view.key !== key) return
|
||||
const data = view.server.data
|
||||
data.project.invalidate()
|
||||
data.session.invalidate(sessionID)
|
||||
data.location.invalidate(location)
|
||||
data.location.vcs.invalidate(location)
|
||||
await data.project.sync()
|
||||
await data.session.sync(sessionID)
|
||||
await Promise.all([data.location.syncInfo(location), data.location.vcs.sync(location)])
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (lifetime.disposed || ctx.signal.aborted || view.key !== key) return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: ctx.t("common.requestFailed"),
|
||||
description: error instanceof Error ? error.message : undefined,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (!lifetime.disposed && !ctx.signal.aborted && view.key === key) setState("initializingGit", false)
|
||||
})
|
||||
}
|
||||
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||
const source = diffs().find((diff) => diff.file === path)
|
||||
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
|
||||
@@ -445,6 +486,8 @@ export function createReviewModel(input: { ctx: Context; view: SessionView; dema
|
||||
kinds,
|
||||
focusFile,
|
||||
hasChanges,
|
||||
initializeGit,
|
||||
initializingGit: () => state.initializingGit,
|
||||
loadDiff,
|
||||
mode,
|
||||
noGit: createMemo(() => !!view.project && !view.project.vcs),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Match, Show, Switch } from "solid-js"
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewEmptyNoGitV2 } from "@opencode/session-ui/v2/session-review-empty-no-git-v2"
|
||||
import { Select } from "@opencode/ui/select"
|
||||
import { useExtension } from "../sdk"
|
||||
import type { ChangeMode, ReviewModel } from "./model"
|
||||
@@ -12,7 +13,7 @@ export function ReviewTitle(props: { review: ReviewModel }) {
|
||||
return ctx.t("ui.sessionReview.title.lastTurn")
|
||||
}
|
||||
return (
|
||||
<Show when={props.review.canReview()}>
|
||||
<Show when={props.review.canReview() && props.review.options().length > 0}>
|
||||
<Select
|
||||
options={props.review.options()}
|
||||
current={props.review.mode()}
|
||||
@@ -28,7 +29,6 @@ export function ReviewTitle(props: { review: ReviewModel }) {
|
||||
export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }) {
|
||||
const ctx = useExtension()
|
||||
const loading = () => !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return ctx.t("empty.git")
|
||||
if (props.review.mode() === "branch") return ctx.t("empty.branch")
|
||||
@@ -39,14 +39,9 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
|
||||
<Match when={loading()}>
|
||||
<div class={props.loadingClass}>{ctx.t("loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="text-14-medium text-text-strong">{ctx.t("git.title")}</div>
|
||||
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
|
||||
{ctx.t("git.description")}
|
||||
</div>
|
||||
</div>
|
||||
<Match when={props.review.noGit()}>
|
||||
<div class="h-full flex flex-col">
|
||||
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
@@ -61,16 +56,13 @@ export function ReviewEmpty(props: { review: ReviewModel; loadingClass: string }
|
||||
export function ReviewPanelEmpty(props: { review: ReviewModel }) {
|
||||
const ctx = useExtension()
|
||||
const loading = () => !props.review.ready()
|
||||
const noGit = () => props.review.noGit()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="px-6 py-4 text-text-weak">{ctx.t("loadingChanges")}</div>
|
||||
</Match>
|
||||
<Match when={noGit()}>
|
||||
<div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
|
||||
<div class="text-14-regular text-text-weak max-w-56">{ctx.t("git.description")}</div>
|
||||
</div>
|
||||
<Match when={props.review.noGit()}>
|
||||
<SessionReviewEmptyNoGitV2 pending={props.review.initializingGit()} onInitGit={props.review.initializeGit} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<SessionReviewEmptyChangesV2 />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -26,6 +26,7 @@ export interface VcsDiffInput extends VcsScope {
|
||||
export interface VcsDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly init?: (input: VcsScope) => Effect.Effect<void, unknown>
|
||||
readonly info: (input: VcsScope) => Effect.Effect<Vcs.Info, unknown>
|
||||
readonly base?: (input: VcsScope) => Effect.Effect<Vcs.Base | null, unknown>
|
||||
readonly branches: (input: VcsBranchesInput) => Effect.Effect<Vcs.BranchList, unknown>
|
||||
@@ -33,7 +34,7 @@ export interface VcsDefinition {
|
||||
readonly diff: (input: VcsDiffInput) => Effect.Effect<readonly FileDiff.Info[], unknown>
|
||||
}
|
||||
|
||||
export interface VcsDomain extends VcsApi<unknown> {
|
||||
export interface VcsDomain extends Omit<VcsApi<unknown>, "init"> {
|
||||
readonly transform: Transform<VcsEditor>
|
||||
readonly reload: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
@@ -518,10 +518,12 @@ export function fromPromise(plugin: Plugin) {
|
||||
host.vcs.transform((editor) => {
|
||||
callback({
|
||||
add: (definition) => {
|
||||
const init = definition.init?.bind(definition)
|
||||
const base = definition.base?.bind(definition)
|
||||
editor.add({
|
||||
id: definition.id,
|
||||
name: definition.name,
|
||||
init: init ? (input) => attempt((signal) => init(input, { signal })) : undefined,
|
||||
info: (input) => attempt((signal) => definition.info(input, { signal })),
|
||||
base: base ? (input) => attempt((signal) => base(input, { signal })) : undefined,
|
||||
branches: (input) => attempt((signal) => definition.branches(input, { signal })),
|
||||
|
||||
@@ -25,6 +25,7 @@ export interface VcsDiffInput extends VcsScope {
|
||||
export interface VcsDefinition {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly init?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<void>
|
||||
readonly info: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Info>
|
||||
readonly base?: (input: VcsScope, context: { readonly signal: AbortSignal }) => Promise<Vcs.Base | null>
|
||||
readonly branches: (input: VcsBranchesInput, context: { readonly signal: AbortSignal }) => Promise<Vcs.BranchList>
|
||||
@@ -32,7 +33,7 @@ export interface VcsDefinition {
|
||||
readonly diff: (input: VcsDiffInput, context: { readonly signal: AbortSignal }) => Promise<readonly FileDiff.Info[]>
|
||||
}
|
||||
|
||||
export interface VcsDomain extends VcsApi {
|
||||
export interface VcsDomain extends Omit<VcsApi, "init"> {
|
||||
readonly transform: Transform<VcsEditor>
|
||||
readonly reload: () => Promise<void>
|
||||
}
|
||||
|
||||
+628
-20
@@ -48,6 +48,106 @@
|
||||
"summary": "Get server info"
|
||||
}
|
||||
},
|
||||
"/api/pair": {
|
||||
"post": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.pair",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingCode",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingCode"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a short-lived, single-use code for a /auth/connect/:code pairing link.",
|
||||
"summary": "Create pairing code"
|
||||
}
|
||||
},
|
||||
"/auth/connect/{code}": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "server.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "code",
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "PairingSession",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PairingSession"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Redeem a pairing code. Browsers receive a session cookie and a redirect to the web app; requests that accept JSON receive a session token to use as the password.",
|
||||
"summary": "Redeem pairing code"
|
||||
}
|
||||
},
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
@@ -886,9 +986,19 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "SessionNotFoundError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SessionNotFoundErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Create a session at the requested location.",
|
||||
"description": "Create a session at the requested location. A parentID creates a linked child session at its parent's location.",
|
||||
"summary": "Create session",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
@@ -907,6 +1017,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"parentID": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^ses"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"title": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -4508,6 +4629,21 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -4591,21 +4727,6 @@
|
||||
"pattern": "^frm_"
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "message",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
@@ -7197,6 +7318,125 @@
|
||||
"summary": "List MCP resources"
|
||||
}
|
||||
},
|
||||
"/api/credential": {
|
||||
"get": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "List every stored integration credential, including its secret value.",
|
||||
"summary": "List credentials"
|
||||
},
|
||||
"post": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "credential.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"$ref": "#/components/schemas/Credential.Entry"
|
||||
}
|
||||
},
|
||||
"required": ["data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Store an integration credential. It becomes the integration's active credential unless activate is false and the integration already has one. Fails with a conflict when the requested ID already exists.",
|
||||
"summary": "Create credential",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Credential.CreateInput"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
@@ -11451,6 +11691,124 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/vcs/init": {
|
||||
"post": {
|
||||
"tags": ["vcs"],
|
||||
"operationId": "vcs.init",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
},
|
||||
{
|
||||
"name": "provider",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "<No Content>"
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/InvalidRequestErrorEncoded"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"409": {
|
||||
"description": "ConflictError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ConflictErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"501": {
|
||||
"description": "VcsInitNotSupportedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VcsInitNotSupportedErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"503": {
|
||||
"description": "ServiceUnavailableError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ServiceUnavailableErrorEncoded"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
|
||||
"summary": "Initialize VCS repository"
|
||||
}
|
||||
},
|
||||
"/api/vcs": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
@@ -13237,6 +13595,27 @@
|
||||
"required": ["command"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.Capabilities": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"input": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Config.Model.CostEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13328,7 +13707,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"capabilities": {
|
||||
"$ref": "#/components/schemas/Model.Capabilities"
|
||||
"$ref": "#/components/schemas/Config.Model.Capabilities"
|
||||
},
|
||||
"variants": {
|
||||
"type": "array",
|
||||
@@ -13433,8 +13812,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -13631,6 +14029,9 @@
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["key", "oauth"]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "label", "method"],
|
||||
@@ -13645,6 +14046,9 @@
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/components/schemas/Connection.Status"
|
||||
}
|
||||
},
|
||||
"required": ["type", "name"],
|
||||
@@ -13660,6 +14064,128 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"Connection.Status": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["needs_auth"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"url": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["status", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.CreateInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
},
|
||||
"activate": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["integrationID", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Entry": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"integrationID": {
|
||||
"type": "string"
|
||||
},
|
||||
"label": {
|
||||
"type": "string"
|
||||
},
|
||||
"active": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"value": {
|
||||
"$ref": "#/components/schemas/Credential.Value"
|
||||
}
|
||||
},
|
||||
"required": ["id", "integrationID", "label", "active", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Key": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["key"]
|
||||
},
|
||||
"key": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"configuration": {
|
||||
"$ref": "#/components/schemas/Form.Answer_1"
|
||||
}
|
||||
},
|
||||
"required": ["type", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.OAuth": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["oauth"]
|
||||
},
|
||||
"methodID": {
|
||||
"type": "string"
|
||||
},
|
||||
"refresh": {
|
||||
"type": "string"
|
||||
},
|
||||
"access": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"required": ["type", "methodID", "refresh", "access", "expires"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Credential.Value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.OAuth"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/Credential.Key"
|
||||
}
|
||||
]
|
||||
},
|
||||
"FileDiff.Info": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13746,6 +14272,12 @@
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.Answer_1": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Form.Value"
|
||||
}
|
||||
},
|
||||
"Form.BooleanField": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -15709,6 +16241,29 @@
|
||||
"Money.USDPerMillionTokens": {
|
||||
"type": "number"
|
||||
},
|
||||
"PairingCode": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_in": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": ["code", "expires_in"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"PairingSession": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"token": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["token"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Permission.Effect": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny", "ask"]
|
||||
@@ -16601,8 +17156,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"headerTimeout": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"chunkTimeout": {
|
||||
"type": "number"
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number"
|
||||
},
|
||||
{
|
||||
"type": "boolean",
|
||||
"enum": [false]
|
||||
}
|
||||
]
|
||||
},
|
||||
"compaction": {
|
||||
"$ref": "#/components/schemas/Provider.Compaction"
|
||||
@@ -18234,6 +18808,16 @@
|
||||
"type": "integer",
|
||||
"minimum": 100,
|
||||
"maximum": 599
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["body"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["type", "message"],
|
||||
@@ -18653,6 +19237,9 @@
|
||||
"exit": {
|
||||
"type": "number"
|
||||
},
|
||||
"signal": {
|
||||
"type": "string"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
@@ -18929,6 +19516,23 @@
|
||||
"type": "string",
|
||||
"enum": ["working", "branch", "committed"]
|
||||
},
|
||||
"VcsInitNotSupportedErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["VcsInitNotSupportedError"]
|
||||
},
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["_tag", "providerID", "message"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"WebSearch.Provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -19053,6 +19657,10 @@
|
||||
"WorktreeErrorEncoded": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_tag": {
|
||||
"type": "string",
|
||||
"enum": ["WorktreeError"]
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"enum": ["WorktreeError"]
|
||||
@@ -19078,7 +19686,7 @@
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["name", "data"],
|
||||
"required": ["_tag", "name", "data"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -3,9 +3,20 @@ import { Location } from "@opencode/schema/location"
|
||||
import { NonNegativeInt, PositiveInt, optional } from "@opencode/schema/schema"
|
||||
import { Vcs } from "@opencode/schema/vcs"
|
||||
import { Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
import { ServiceUnavailableError } from "../errors.js"
|
||||
import { ConflictError, InvalidRequestError, ServiceUnavailableError } from "../errors.js"
|
||||
|
||||
const InitQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
provider: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export class VcsInitNotSupportedError extends Schema.TaggedError<VcsInitNotSupportedError>()(
|
||||
"VcsInitNotSupportedError",
|
||||
{ providerID: Schema.String, message: Schema.String },
|
||||
{ httpApiStatus: 501 },
|
||||
) {}
|
||||
|
||||
const BranchesQuery = Schema.Struct({
|
||||
...LocationQuery.fields,
|
||||
@@ -21,6 +32,22 @@ const DiffQuery = Schema.Struct({
|
||||
})
|
||||
|
||||
export const VcsGroup = HttpApiGroup.make("server.vcs")
|
||||
.add(
|
||||
HttpApiEndpoint.post("vcs.init", "/api/vcs/init", {
|
||||
query: InitQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [ConflictError, InvalidRequestError, VcsInitNotSupportedError, ServiceUnavailableError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "vcs.init",
|
||||
summary: "Initialize VCS repository",
|
||||
description:
|
||||
"Initialize a repository using the selected VCS provider in a markerless project's directory and refresh its location services. Omitting provider defaults to git; built-in git and hg providers support initialization. An unknown provider returns 400; a registered provider without init returns 501.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("vcs.get", "/api/vcs", {
|
||||
query: LocationQuery,
|
||||
|
||||
@@ -6,7 +6,8 @@ import { ProjectNotFoundError } from "../errors.js"
|
||||
|
||||
const root = "/api/worktree"
|
||||
|
||||
export class WorktreeError extends Schema.Error<WorktreeError>("WorktreeError")(
|
||||
export class WorktreeError extends Schema.TaggedError<WorktreeError>()(
|
||||
"WorktreeError",
|
||||
{
|
||||
name: Schema.Literal("WorktreeError"),
|
||||
data: Schema.Struct({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.21",
|
||||
"version": "2.0.22",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
export * as IdeEvent from "./ide-event.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Event } from "./event.js"
|
||||
|
||||
export const Installed = Event.ephemeral({
|
||||
type: "ide.installed",
|
||||
schema: {
|
||||
ide: Schema.String,
|
||||
},
|
||||
})
|
||||
|
||||
export const Definitions = Event.inventory(Installed)
|
||||
@@ -490,15 +490,6 @@ export type Assistant = Omit<Types.DeepMutable<Schema.Schema.Type<typeof Assista
|
||||
export const Info = Schema.Union([User, Assistant]).annotate({ discriminator: "role", identifier: "SessionV1.Message" })
|
||||
export type Info = User | Assistant
|
||||
|
||||
export const WithParts = Schema.Struct({
|
||||
info: Info,
|
||||
parts: Schema.Array(Part),
|
||||
})
|
||||
export type WithParts = {
|
||||
info: Info
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
const options = {
|
||||
durable: {
|
||||
aggregate: "sessionID",
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user