diff --git a/.opencode/bun.lock b/.opencode/bun.lock index b979275..fbdc461 100644 --- a/.opencode/bun.lock +++ b/.opencode/bun.lock @@ -4,8 +4,7 @@ "workspaces": { "": { "dependencies": { - "@opencode-ai/plugin": "0.0.0-next-16741", - "zod": "4.1.8", + "@opencode-ai/plugin": "1.18.13", }, "devDependencies": { "@types/node": "^24.7.2", @@ -16,14 +15,6 @@ "packages": { "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], - "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], - - "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], - - "@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="], - - "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], @@ -36,212 +27,60 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - "@opencode-ai/ai": ["@opencode-ai/ai@0.0.0-next-16741", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-16741", "@smithy/eventstream-codec": "4.2.14", "@smithy/util-utf8": "4.2.2", "aws4fetch": "1.0.20", "effect": "4.0.0-beta.101", "google-auth-library": "10.5.0" } }, "sha512-nmVBHgT/rVA1JWWn1lIYP03JZVd+mbZc1bYPcV1fH4QMWlVmr9aDiU15HbhrXfSqkaRd1DBENqHR6zhW+S2iIA=="], + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.13", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-2H9YT80M1PYElpG+lmd/9kGqsNouiJIBCUhLblmgFwoSrB4wyahgkCS6NcFQR/AYXNH4I1Yd3lmQcVaEPu1qNg=="], - "@opencode-ai/client": ["@opencode-ai/client@0.0.0-next-16741", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-next-16741", "@opencode-ai/schema": "0.0.0-next-16741" }, "peerDependencies": { "effect": "4.0.0-beta.101" }, "optionalPeers": ["effect"] }, "sha512-myhJUUk5llhbcngRlxTkykUVtaBvsCTlPPi2PTHbCSWE+LFdByVbuYqGNFkKzM6Q3qJ0bS8Xm4BAlA4oc7wayw=="], - - "@opencode-ai/plugin": ["@opencode-ai/plugin@0.0.0-next-16741", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/ai": "0.0.0-next-16741", "@opencode-ai/client": "0.0.0-next-16741", "@opencode-ai/schema": "0.0.0-next-16741", "@opencode-ai/sdk": "1.18.5", "@standard-schema/spec": "1.1.0", "effect": "4.0.0-beta.101", "zod": "4.1.8" }, "peerDependencies": { "@opencode-ai/theme": "0.0.0-next-16741", "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5", "solid-js": ">=1.9.0" }, "optionalPeers": ["@opencode-ai/theme", "@opentui/core", "@opentui/keymap", "@opentui/solid", "solid-js"] }, "sha512-AMCtMGs/XioqsOSvwZFs/5BsKfovYdSpxcI9QTxzm9IHfuCe3gsffT9CgnMqCKNyV0snz9eVA3spd4UpbTeVSA=="], - - "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-next-16741", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-16741", "effect": "4.0.0-beta.101" } }, "sha512-2UWB5i8uXP9cojJus2A7z1DZhJIbsr81OWuUx1+MsaPMCLmJYkrvOzdSEhfFSuVRgNy1HY5X1n7QHmRPZzrIvg=="], - - "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-next-16741", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-beta.101" } }, "sha512-PzFe3hAI2gTwKwrbFssHvAiP9D7w/JI5W2VzW+gB0wqU5WT4MOxPGGQRJ72+xXGqhQZCeoyN15as4hY4jeOXXA=="], - - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.5", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-7KgMvP5/1oxbhHj6kYBtPSTEdFKYpUeEYOzBTKdzSaRpapUpFFdn6Hkus3rr0rljO0kukWZIgRd3DrVBwTULGA=="], - - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], - - "@smithy/core": ["@smithy/core@3.31.1", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg=="], - - "@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="], - - "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], - - "@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="], - - "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.4.16", "", { "dependencies": { "@smithy/core": "^3.31.1", "tslib": "^2.6.2" } }, "sha512-HxG8CH8HXUO6JqH+ftXCJm1XrISkcZNrOLGkMYiz90wT3FPBZZE/syDNYJ/iKQEi91gN2J/SnyEhHjcJPEVSFw=="], - - "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.4.16", "", { "dependencies": { "@smithy/core": "^3.31.1", "tslib": "^2.6.2" } }, "sha512-x9/taZ/r/nEL6SBfdV+M18VpJU16yzehn2q1XHNmXWEp3g5U4NBl2oMBVw7YKdmH2HiKQ/ZqDW5JjBDNzgLKrg=="], - - "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@types/node": ["@types/node@24.12.4", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA=="], - "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], - - "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - - "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], - - "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="], - - "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], - - "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - - "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], - - "brace-expansion": ["brace-expansion@2.1.4", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg=="], - - "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], - - "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - - "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], - "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="], - "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], - - "effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="], - - "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], - - "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], - - "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], - - "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], - - "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], - - "gaxios": ["gaxios@7.3.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-RB5vLV+vvQeoFPCX4QMK6/hjVkbIamPp1QSUD0CiZcnj12qbpiL+pLbYtgD+oZkWl0tl9z+o2Utp+MpM3QRhBA=="], - - "gcp-metadata": ["gcp-metadata@8.1.4", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw=="], - - "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - - "google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="], - - "google-logging-utils": ["google-logging-utils@1.2.0", "", {}, "sha512-WE9av4wKDZgRjBwgVUabocx8T6/7o3Ca1Fat46FXDhXVAFibzNadedcOXrdgd1Kzmk8tsk/9ZH89Wyf/SqeZ3A=="], - - "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], - - "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], - "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], - - "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], - "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], - "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], - - "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], - "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], - - "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], - - "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], + "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="], "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], - "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], - - "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], - - "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "multipasta": ["multipasta@0.2.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], - "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], - "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], - "pure-rand": ["pure-rand@8.4.0", "", {}, "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A=="], - "rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="], - - "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - - "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], - - "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], - - "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], - "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], - - "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "yaml": ["yaml@2.9.0", "", { "bin": "bin.mjs" }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "zod": ["zod@4.1.8", "", {}, ""], - - "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], - - "gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="], - - "gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], - - "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - - "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - - "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - - "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], - - "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - - "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/.opencode/package.json b/.opencode/package.json index 66983d0..3db9d87 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -4,8 +4,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@opencode-ai/plugin": "0.0.0-next-16741", - "zod": "4.1.8" + "@opencode-ai/plugin": "1.18.13" }, "devDependencies": { "@types/node": "^24.7.2", diff --git a/.opencode/plugins/tjwater.ts b/.opencode/plugins/tjwater.ts deleted file mode 100644 index 3db089a..0000000 --- a/.opencode/plugins/tjwater.ts +++ /dev/null @@ -1,44 +0,0 @@ -import apply_layer_style from "../tools/apply_layer_style.js"; -import geocode from "../tools/geocode.js"; -import locate_features from "../tools/locate_features.js"; -import memory_manager from "../tools/memory_manager.js"; -import render_junctions from "../tools/render_junctions.js"; -import session_search from "../tools/session_search.js"; -import show_chart from "../tools/show_chart.js"; -import skill_manager from "../tools/skill_manager.js"; -import store_render_ref from "../tools/store_render_ref.js"; -import tjwater_cli from "../tools/tjwater_cli.js"; -import view_history from "../tools/view_history.js"; -import view_scada from "../tools/view_scada.js"; -import web_search from "../tools/web_search.js"; -import zoom_to_map from "../tools/zoom_to_map.js"; -import { Plugin } from "@opencode-ai/plugin"; - -const definitions = { - apply_layer_style, - geocode, - locate_features, - memory_manager, - render_junctions, - session_search, - show_chart, - skill_manager, - store_render_ref, - tjwater_cli, - view_history, - view_scada, - web_search, - zoom_to_map, -}; - -export default Plugin.define({ - id: "tjwater-tools", - async setup(context) { - const registration = await context.tool.transform((tools) => { - for (const [name, definition] of Object.entries(definitions)) { - tools.add({ name, ...definition }); - } - }); - return () => registration.dispose(); - }, -}); diff --git a/.opencode/tools/apply_layer_style.ts b/.opencode/tools/apply_layer_style.ts index b98c42f..95e183b 100644 --- a/.opencode/tools/apply_layer_style.ts +++ b/.opencode/tools/apply_layer_style.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; export default tool({ description: diff --git a/.opencode/tools/defineTool.ts b/.opencode/tools/defineTool.ts deleted file mode 100644 index d3bc64d..0000000 --- a/.opencode/tools/defineTool.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { z } from "zod"; - -type ToolContext = { - sessionID: string; -}; - -type ToolDefinition = { - description: string; - args: Shape; - execute: ( - args: z.infer>, - context: ToolContext, - ) => Promise | string; -}; - -const defineTool = ( - definition: ToolDefinition, -) => { - const inputSchema = z.object(definition.args); - return { - description: definition.description, - input: z.toJSONSchema(inputSchema), - async execute(input: unknown, context: ToolContext) { - return { - content: await definition.execute(inputSchema.parse(input), context), - }; - }, - }; -}; - -export const tool = Object.assign(defineTool, { schema: z }); diff --git a/.opencode/tools/geocode.ts b/.opencode/tools/geocode.ts index 67c1a2d..d113ad5 100644 --- a/.opencode/tools/geocode.ts +++ b/.opencode/tools/geocode.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; diff --git a/.opencode/tools/locate_features.ts b/.opencode/tools/locate_features.ts index 6160bee..1ff3b49 100644 --- a/.opencode/tools/locate_features.ts +++ b/.opencode/tools/locate_features.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; export default tool({ description: "在前端地图上定位并高亮指定的管网要素。", diff --git a/.opencode/tools/memory_manager.ts b/.opencode/tools/memory_manager.ts index be536d6..6de8faf 100644 --- a/.opencode/tools/memory_manager.ts +++ b/.opencode/tools/memory_manager.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; import { MemoryStore } from "../../src/memory/store.js"; import { getRuntimeSessionContext, diff --git a/.opencode/tools/render_junctions.ts b/.opencode/tools/render_junctions.ts index 6d44c7e..49043f2 100644 --- a/.opencode/tools/render_junctions.ts +++ b/.opencode/tools/render_junctions.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; export default tool({ description: diff --git a/.opencode/tools/session_search.ts b/.opencode/tools/session_search.ts index b52de47..50dcea9 100644 --- a/.opencode/tools/session_search.ts +++ b/.opencode/tools/session_search.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; diff --git a/.opencode/tools/show_chart.ts b/.opencode/tools/show_chart.ts index 3aa789d..e133305 100644 --- a/.opencode/tools/show_chart.ts +++ b/.opencode/tools/show_chart.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; export default tool({ description: diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index 0aa720a..82cc8e7 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; import { SkillStore } from "../../src/skills/store.js"; import { diff --git a/.opencode/tools/store_render_ref.ts b/.opencode/tools/store_render_ref.ts index 5997337..e9fab74 100644 --- a/.opencode/tools/store_render_ref.ts +++ b/.opencode/tools/store_render_ref.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; diff --git a/.opencode/tools/tjwater_cli.ts b/.opencode/tools/tjwater_cli.ts index 9c7f5d0..e72a0f6 100644 --- a/.opencode/tools/tjwater_cli.ts +++ b/.opencode/tools/tjwater_cli.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; diff --git a/.opencode/tools/view_history.ts b/.opencode/tools/view_history.ts index 83aaebf..29c68a9 100644 --- a/.opencode/tools/view_history.ts +++ b/.opencode/tools/view_history.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; export default tool({ description: "为选定的管网要素打开前端的历史记录或计算结果面板。", diff --git a/.opencode/tools/view_scada.ts b/.opencode/tools/view_scada.ts index b930f2d..ad05355 100644 --- a/.opencode/tools/view_scada.ts +++ b/.opencode/tools/view_scada.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; export default tool({ description: "打开前端的 SCADA 监测数据历史面板。", diff --git a/.opencode/tools/web_search.ts b/.opencode/tools/web_search.ts index 6a92139..fd64c2a 100644 --- a/.opencode/tools/web_search.ts +++ b/.opencode/tools/web_search.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; const internalBaseUrl = process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; diff --git a/.opencode/tools/zoom_to_map.ts b/.opencode/tools/zoom_to_map.ts index 865e227..986533b 100644 --- a/.opencode/tools/zoom_to_map.ts +++ b/.opencode/tools/zoom_to_map.ts @@ -1,4 +1,4 @@ -import { tool } from "./defineTool.js"; +import { tool } from "@opencode-ai/plugin"; export default tool({ description: diff --git a/Dockerfile b/Dockerfile index 3770b9b..6fb2791 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,10 @@ -FROM ubuntu:24.04 AS base +FROM smanx/opencode:1.18.4@sha256:719821683648f5c28431ed28a068cb96f92c15374148783a1e0d34d5e2f16ed3 AS base USER root ARG UBUNTU_APT_MIRROR= -ARG OPENCODE_VERSION=0.0.0-next-16741 ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ARG PYPI_TRUSTED_HOST=pypi.tuna.tsinghua.edu.cn ENV VIRTUAL_ENV=/opt/venv -ENV PATH="$VIRTUAL_ENV/bin:/root/.local/bin:/root/.bun/bin:$PATH" +ENV PATH="$VIRTUAL_ENV/bin:/root/.local/bin:$PATH" ENV PIP_INDEX_URL=${PYPI_INDEX_URL} ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST} ENV UV_INDEX_URL=${PYPI_INDEX_URL} @@ -17,7 +16,6 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \ sed -i "s|http://archive.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g; s|http://security.ubuntu.com|https://${UBUNTU_APT_MIRROR}|g" /etc/apt/sources.list.d/*.sources 2>/dev/null || true; \ fi && \ apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ curl \ jq \ unzip \ @@ -27,8 +25,6 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \ mv /usr/local/bun-linux-x64 /usr/local/bun && \ ln -sf /usr/local/bun/bun /usr/local/bin/bun && \ rm -f /tmp/bun.zip && \ - bun install --global --trust "@opencode-ai/cli@${OPENCODE_VERSION}" && \ - opencode2 --version && \ curl -LsSf https://astral.sh/uv/install.sh | sh && \ ln -s /root/.local/bin/uv /usr/local/bin/uv && \ ln -sf /usr/bin/python3 /usr/local/bin/python && \ @@ -62,32 +58,29 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules +COPY package.json bun.lock ./ COPY tsconfig.json opencode.json README.md .gitignore ./ COPY src ./src COPY cli ./cli COPY .opencode ./.opencode RUN bun run check -FROM base AS runner +FROM build AS runner WORKDIR /app ENV NODE_ENV=production ENV HOST=0.0.0.0 ENV PORT=8787 +ENV OPENCODE_HOST=127.0.0.1 +ENV OPENCODE_HOSTNAME=127.0.0.1 ENV TJWATER_CLI_PATH=./cli/tjwater-cli -COPY --from=deps /app/node_modules ./node_modules -COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules -COPY package.json bun.lock ./ -COPY tsconfig.json opencode.json .gitignore ./ -COPY src ./src -COPY .opencode ./.opencode -COPY cli ./cli - COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh ./cli/tjwater-cli ENTRYPOINT ["/entrypoint.sh"] EXPOSE 8787 +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ + CMD curl --fail --silent --show-error "http://127.0.0.1:${PORT}/health" >/dev/null || exit 1 CMD ["bun", "src/server.ts"] diff --git a/README.md b/README.md index 041a9b5..3a264b8 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ ## 主要能力 - 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。 -- 使用 OpenCode 2.0 beta client;支持 embedded `opencode2` service,也可连接外部 V2 service。 +- 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。 - 管理前端 `session_id` 与 OpenCode session 的映射。 - 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。 -- 通过 `.opencode/plugins` 注册 `.opencode/tools` 中的工具实现,驱动地图定位、图表、SCADA、历史数据和业务 API 调用。 +- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。 - 通过 `data/` 保存运行时会话元数据、结果引用和本地状态。 ## 目录结构 @@ -21,8 +21,7 @@ src/runtime/ OpenCode 运行时管理 src/session/ 会话映射和运行上下文 src/mcp/ MCP 服务与工具桥接 .opencode/agents/ Agent prompt 和模型行为配置 -.opencode/plugins/ OpenCode V2 插件入口 -.opencode/tools/ 插件注册的工具实现 +.opencode/tools/ OpenCode 自定义工具 .opencode/skills/ 可复用分析工作流 node-tests/ Node CLI 测试 data/ 本地运行时数据,禁止提交 @@ -61,14 +60,14 @@ docker build -t tjwater-agent:local . ## 运行模式 -Embedded 模式由服务进程拉起本机 `opencode2 serve --service`。本地需要安装与 `@opencode-ai/client`、`@opencode-ai/plugin` 对齐的 V2 beta CLI;Docker 镜像会安装仓库固定版本: +当前运行时使用 OpenCode 稳定版 1.x CLI,并通过稳定版 SDK 的 `@opencode-ai/sdk/v2` HTTP 客户端访问运行时;这与 `opencode2` 及 `@opencode-ai/client` 的 2.0 beta 运行时不同。Embedded 模式由服务进程拉起本机 OpenCode: ```bash OPENCODE_MODE=embedded TJWATER_API_BASE_URL=http://127.0.0.1:8000 ``` -Client 模式连接外部 OpenCode V2 service: +Client 模式连接外部 OpenCode server: ```bash OPENCODE_MODE=client @@ -78,6 +77,10 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000 本地可使用 `.local.env` 保存开发配置;系统环境变量优先级更高。 +服务会在 HTTP 端口开始监听前完成 OpenCode 健康检查、临时会话创建和工具目录加载。`GET /health` 返回 `ready: true` 与 `warmed_up: true` 时,表示冷启动预热已经完成。开发环境会输出各预热阶段的耗时。 + +`opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。 + ## 配置与安全 不要提交 `.env`、`.local.env`、`data/`、`logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。 diff --git a/bun.lock b/bun.lock index d622230..ab167bf 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "tjwater-agent", "dependencies": { - "@opencode-ai/client": "0.0.0-next-16741", + "@opencode-ai/sdk": "1.18.13", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", @@ -26,28 +26,10 @@ "packages": { "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="], - "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], - - "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="], - - "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="], - - "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="], - - "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], - - "@opencode-ai/client": ["@opencode-ai/client@0.0.0-next-16741", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-next-16741", "@opencode-ai/schema": "0.0.0-next-16741" }, "peerDependencies": { "effect": "4.0.0-beta.101" }, "optionalPeers": ["effect"] }, "sha512-myhJUUk5llhbcngRlxTkykUVtaBvsCTlPPi2PTHbCSWE+LFdByVbuYqGNFkKzM6Q3qJ0bS8Xm4BAlA4oc7wayw=="], - - "@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-next-16741", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-next-16741", "effect": "4.0.0-beta.101" } }, "sha512-2UWB5i8uXP9cojJus2A7z1DZhJIbsr81OWuUx1+MsaPMCLmJYkrvOzdSEhfFSuVRgNy1HY5X1n7QHmRPZzrIvg=="], - - "@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-next-16741", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-beta.101" } }, "sha512-PzFe3hAI2gTwKwrbFssHvAiP9D7w/JI5W2VzW+gB0wqU5WT4MOxPGGQRJ72+xXGqhQZCeoyN15as4hY4jeOXXA=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="], @@ -98,6 +80,8 @@ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "dateformat": ["dateformat@4.6.3", "", {}, "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA=="], "debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -106,16 +90,12 @@ "destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="], - "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="], - "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], @@ -132,16 +112,12 @@ "express": ["express@4.22.1", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.3", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g=="], - "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="], - "fast-copy": ["fast-copy@4.0.3", "", {}, "sha512-58apWr0GUiDFM8+3afrO6eYwJBn9ZAhDOzG3L+/9llab/haCARS2UIfffmOurYLwbgDRs8n0rfr6qAAPEAuAQw=="], "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], "finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="], - "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="], - "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], "fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], @@ -166,13 +142,11 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="], - "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -192,16 +166,8 @@ "ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - "msgpackr": ["msgpackr@2.0.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA=="], - - "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="], - - "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="], - "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], @@ -216,6 +182,8 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + "path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="], "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], @@ -232,8 +200,6 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], - "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="], - "qs": ["qs@6.14.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -258,6 +224,10 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], @@ -278,8 +248,6 @@ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], - "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="], - "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], @@ -290,10 +258,10 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], diff --git a/entrypoint.sh b/entrypoint.sh index 7ed393f..770482c 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,6 +2,6 @@ set -e # 直接启动 TJWaterAgent -# V2 client service helper 会根据 src/runtime/opencode.ts 自动管理 opencode2 service +# SDK 会根据 src/runtime/opencode.ts 中的逻辑自动管理 opencode 实例 echo "Starting TJWaterAgent..." exec bun run start diff --git a/opencode.json b/opencode.json index 19b07b0..d512492 100644 --- a/opencode.json +++ b/opencode.json @@ -1,25 +1,35 @@ { "$schema": "https://opencode.ai/config.json", - "providers": { + "provider": { "deepseek": { - "settings": { + "options": { "apiKey": "{env:DEEPSEEK_API_KEY}" } } }, "model": "deepseek/deepseek-v4-flash", - "permissions": [ - { "action": "*", "resource": "*", "effect": "allow" }, - { "action": "external_directory", "resource": "*", "effect": "ask" }, - { "action": "shell", "resource": "rm *", "effect": "ask" }, - { "action": "shell", "resource": "rmdir *", "effect": "ask" }, - { "action": "shell", "resource": "mv *", "effect": "ask" }, - { "action": "shell", "resource": "chmod *", "effect": "ask" }, - { "action": "shell", "resource": "chown *", "effect": "ask" }, - { "action": "shell", "resource": "sudo *", "effect": "ask" }, - { "action": "shell", "resource": "curl *", "effect": "ask" }, - { "action": "shell", "resource": "wget *", "effect": "ask" }, - { "action": "edit", "resource": "*", "effect": "ask" } - ], + "server": { + "hostname": "127.0.0.1", + "port": 4096 + }, + "permission": { + "*": "allow", + "external_directory": "ask", + "bash": { + "*": "allow", + "rm *": "ask", + "rmdir *": "ask", + "mv *": "ask", + "chmod *": "ask", + "chown *": "ask", + "sudo *": "ask", + "curl *": "ask", + "wget *": "ask" + }, + "edit": "ask" + }, + "experimental": { + "continue_loop_on_deny": true + }, "default_agent": "instruction" } diff --git a/package.json b/package.json index d391dfe..76c6875 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "start:prod": "bun run check && bun src/server.ts" }, "dependencies": { - "@opencode-ai/client": "0.0.0-next-16741", + "@opencode-ai/sdk": "1.18.13", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", diff --git a/src/config.ts b/src/config.ts index 8d1fd01..b7d8456 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,9 +41,13 @@ const envSchema = z AGENT_INTERNAL_TOKEN: optionalString(), // Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。 AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), - // OpenCode V2 运行模式:embedded 启动 opencode2 service;client 连接已有 V2 service。 + // opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。 OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), - // OpenCode V2 client 等待会话状态变化的超时时间(毫秒)。 + // embedded opencode server 的监听地址。 + OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), + // embedded opencode server 的监听端口。 + OPENCODE_PORT: z.coerce.number().int().positive().default(4096), + // opencode SDK 启动或连接运行时时的超时时间(毫秒)。 OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), // 默认使用的 opencode 模型标识。 OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-flash"), @@ -51,8 +55,10 @@ const envSchema = z OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson), // opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。 OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"), - // client 模式下,目标 OpenCode V2 service 的基础地址。 + // client 模式下,目标 opencode server 的基础地址。 OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), + // 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。 + OPENCODE_BASE_URL: z.string().url().optional(), // tjwater-cli 可执行文件路径。 TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"), // TJWater 后端 API 的基础地址。 @@ -152,7 +158,11 @@ const normalizedEnv = { ...process.env, OPENCODE_MODE: process.env.OPENCODE_MODE ?? - (process.env.OPENCODE_CLIENT_BASE_URL ? "client" : "embedded"), + (process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL + ? "client" + : "embedded"), + OPENCODE_CLIENT_BASE_URL: + process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL, }; export const config: AppConfig = envSchema.parse(normalizedEnv); diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 7e2a2ba..8bb75f4 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -1,13 +1,15 @@ +import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; + import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js"; import { type SupportedModel } from "../chat/models.js"; import { logger } from "../logger.js"; import { type PermissionReply, type OpencodeRuntimeAdapter, - type RuntimeEvent as OpencodeEvent, } from "../runtime/opencode.js"; import { buildPermissionDetail, + buildPermissionV2Detail, buildReasoningProgressDetail, buildSessionStatusDetail, buildToolProgressDetail, @@ -20,15 +22,23 @@ import { hasToolParams, isPermissionAskedEvent, isPermissionRepliedEvent, + isPermissionV2AskedEvent, + isPermissionV2RepliedEvent, isQuestionAskedEvent, isObjectRecord, isQuestionRejectedEvent, isQuestionRepliedEvent, + isQuestionV2AskedEvent, + isQuestionV2RejectedEvent, + isQuestionV2RepliedEvent, isSessionEvent, isSkillEvent, logDevelopmentDebug, normalizeQuestionAnswers, normalizeQuestionPayload, + normalizeQuestionToolPayload, + normalizeTodoPriority, + normalizeTodoStatus, normalizeToolParams, normalizeToolStatus, type PermissionRequestPayload, @@ -140,7 +150,10 @@ export const streamPromptResponse = async ({ const progressStartedAtMap = new Map(); const finalizedProgressIds = new Set(); const emittedToolParts = new Set(); + const emittedQuestionToolParts = new Set(); const emittedQuestionRequestIds = new Set(); + const partTypes = new Map(); + const pendingPartTextDeltas = new Map(); const reasoningDeltas = new Map(); const reasoningStatuses = new Map(); const toolStatuses = new Map(); @@ -158,10 +171,6 @@ export const streamPromptResponse = async ({ let promptSettled = false; let aborted = signal?.aborted ?? false; let failed = false; - let pendingNextEvent: Promise<{ - type: "event"; - result: IteratorResult; - }> | null = null; const debugContext = { sessionId, clientSessionId, @@ -270,7 +279,7 @@ export const streamPromptResponse = async ({ break; } - pendingNextEvent ??= iterator + const nextEvent = iterator .next() .then((result) => ({ type: "event" as const, result })); const nextPrompt = promptSettled @@ -281,7 +290,7 @@ export const streamPromptResponse = async ({ ); const next = await Promise.race( [ - ...(nextPrompt ? [pendingNextEvent, nextPrompt] : [pendingNextEvent]), + ...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]), ...(abortPromise ? [abortPromise] : []), ], ); @@ -297,7 +306,6 @@ export const streamPromptResponse = async ({ if (next.type === "prompt") { continue; } - pendingNextEvent = null; if (next.result.done) { break; } @@ -318,10 +326,11 @@ export const streamPromptResponse = async ({ } if (event.type === "session.status") { - const nextStatus = event.status.type; + const nextStatus = event.properties.status.type; const nextStatusMessage = - "message" in event.status && typeof event.status.message === "string" - ? event.status.message + "message" in event.properties.status && + typeof event.properties.status.message === "string" + ? event.properties.status.message : null; if ( nextStatus !== lastSessionStatus || @@ -339,14 +348,14 @@ export const streamPromptResponse = async ({ emitProgress({ id: "session-status", phase: "session", - status: event.status.type === "idle" ? "completed" : "running", + status: event.properties.status.type === "idle" ? "completed" : "running", title: - event.status.type === "retry" - ? `模型请求重试中:${event.status.message}` - : event.status.type === "busy" + event.properties.status.type === "retry" + ? `模型请求重试中:${event.properties.status.message}` + : event.properties.status.type === "busy" ? "Agent 正在处理请求" : "Agent 已空闲", - detail: buildSessionStatusDetail(event.status), + detail: buildSessionStatusDetail(event.properties.status), }); continue; } @@ -365,13 +374,13 @@ export const streamPromptResponse = async ({ sawResponseActivity = true; logDevelopmentDebug("permission request received", { ...debugContext, - requestId: event.request.id, - permission: event.request.action, - patterns: event.request.resources, + requestId: event.properties.id, + permission: event.properties.permission, + patterns: event.properties.patterns, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `permission-${event.request.id}`, + id: `permission-${event.properties.id}`, phase: "permission", status: approvalMode === "always" ? "completed" : "running", title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", @@ -382,25 +391,70 @@ export const streamPromptResponse = async ({ }); if (approvalMode === "always") { await runtime.replyPermission({ - requestId: event.request.id, + requestId: event.properties.id, sessionId, reply: "always", }); write("permission_response", { session_id: clientSessionId, - request_id: event.request.id, + request_id: event.properties.id, reply: "always" satisfies PermissionReply, }); continue; } write("permission_request", { session_id: clientSessionId, - request_id: event.request.id, - permission: event.request.action, - patterns: event.request.resources, - target: getPermissionTarget(event.request.metadata), - always: event.request.save ?? [], - tool: event.request.tool, + request_id: event.properties.id, + permission: event.properties.permission, + patterns: event.properties.patterns, + target: getPermissionTarget(event.properties.metadata), + always: event.properties.always, + tool: event.properties.tool, + created_at: Date.now(), + } satisfies PermissionRequestPayload); + continue; + } + + if (isPermissionV2AskedEvent(event)) { + sawResponseActivity = true; + logDevelopmentDebug("permission v2 request received", { + ...debugContext, + requestId: event.properties.id, + action: event.properties.action, + resources: event.properties.resources, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + emitProgress({ + id: `permission-${event.properties.id}`, + phase: "permission", + status: approvalMode === "always" ? "completed" : "running", + title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", + detail: + approvalMode === "always" + ? "当前批准模式为始终允许,已自动允许本次权限请求。" + : buildPermissionV2Detail(event), + }); + if (approvalMode === "always") { + await runtime.replyPermission({ + requestId: event.properties.id, + sessionId, + reply: "always", + }); + write("permission_response", { + session_id: clientSessionId, + request_id: event.properties.id, + reply: "always" satisfies PermissionReply, + }); + continue; + } + write("permission_request", { + session_id: clientSessionId, + request_id: event.properties.id, + permission: event.properties.action, + patterns: event.properties.resources, + target: getPermissionTarget(event.properties.metadata), + always: event.properties.save ?? [], + tool: undefined, created_at: Date.now(), } satisfies PermissionRequestPayload); continue; @@ -410,47 +464,78 @@ export const streamPromptResponse = async ({ sawResponseActivity = true; logDevelopmentDebug("permission request replied", { ...debugContext, - requestId: event.requestId, - reply: event.reply, + requestId: event.properties.requestID, + reply: event.properties.reply, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `permission-${event.requestId}`, + id: `permission-${event.properties.requestID}`, phase: "permission", - status: event.reply === "reject" ? "error" : "completed", + status: event.properties.reply === "reject" ? "error" : "completed", title: - event.reply === "reject" + event.properties.reply === "reject" ? "权限请求已拒绝" : "权限请求已允许", detail: - event.reply === "always" + event.properties.reply === "always" ? "已允许本次请求,并记住同类权限。" - : event.reply === "once" + : event.properties.reply === "once" ? "已允许本次请求。" : "已拒绝本次请求。", }); write("permission_response", { session_id: clientSessionId, - request_id: event.requestId, - reply: event.reply satisfies PermissionReply, + request_id: event.properties.requestID, + reply: event.properties.reply satisfies PermissionReply, }); continue; } - if (isQuestionAskedEvent(event)) { + if (isPermissionV2RepliedEvent(event)) { sawResponseActivity = true; - logDevelopmentDebug("question request received", { + logDevelopmentDebug("permission v2 request replied", { ...debugContext, - requestId: event.request.id, - questionCount: event.request.questions.length, + requestId: event.properties.requestID, + reply: event.properties.reply, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `question-${event.request.id}`, + id: `permission-${event.properties.requestID}`, + phase: "permission", + status: event.properties.reply === "reject" ? "error" : "completed", + title: + event.properties.reply === "reject" + ? "权限请求已拒绝" + : "权限请求已允许", + detail: + event.properties.reply === "always" + ? "已允许本次请求,并记住同类权限。" + : event.properties.reply === "once" + ? "已允许本次请求。" + : "已拒绝本次请求。", + }); + write("permission_response", { + session_id: clientSessionId, + request_id: event.properties.requestID, + reply: event.properties.reply satisfies PermissionReply, + }); + continue; + } + + if (isQuestionAskedEvent(event) || isQuestionV2AskedEvent(event)) { + sawResponseActivity = true; + logDevelopmentDebug("question request received", { + ...debugContext, + requestId: event.properties.id, + questionCount: event.properties.questions.length, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + emitProgress({ + id: `question-${event.properties.id}`, phase: "question", status: "running", title: "等待用户补充信息", - detail: event.request.questions + detail: event.properties.questions .map((question) => question.question) .join("\n"), }); @@ -460,40 +545,40 @@ export const streamPromptResponse = async ({ continue; } - if (isQuestionRepliedEvent(event)) { + if (isQuestionRepliedEvent(event) || isQuestionV2RepliedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request replied", { ...debugContext, - requestId: event.requestId, + requestId: event.properties.requestID, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `question-${event.requestId}`, + id: `question-${event.properties.requestID}`, phase: "question", status: "completed", title: "已收到补充信息", - detail: normalizeQuestionAnswers(event.answers) + detail: normalizeQuestionAnswers(event.properties.answers) .map((answer) => answer.join("、")) .filter(Boolean) .join("\n"), }); write("question_response", { session_id: clientSessionId, - request_id: event.requestId, - answers: normalizeQuestionAnswers(event.answers), + request_id: event.properties.requestID, + answers: normalizeQuestionAnswers(event.properties.answers), }); continue; } - if (isQuestionRejectedEvent(event)) { + if (isQuestionRejectedEvent(event) || isQuestionV2RejectedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request rejected", { ...debugContext, - requestId: event.requestId, + requestId: event.properties.requestID, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `question-${event.requestId}`, + id: `question-${event.properties.requestID}`, phase: "question", status: "completed", title: "已跳过补充信息", @@ -501,7 +586,7 @@ export const streamPromptResponse = async ({ }); write("question_response", { session_id: clientSessionId, - request_id: event.requestId, + request_id: event.properties.requestID, rejected: true, }); continue; @@ -532,209 +617,261 @@ export const streamPromptResponse = async ({ }); } - if (event.type === "text.delta") { - sawResponseActivity = true; - if (!firstTokenLogged) { - firstTokenLogged = true; - logDevelopmentDebug("first response token emitted", { - ...debugContext, - partId: event.partId, - elapsedMs: Math.max(0, Date.now() - requestStartedAt), - sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), - }); + if (event.type === "message.updated") { + if (event.properties.info.role === "assistant") { + sawResponseActivity = true; } - emittedText = true; - write("token", { - session_id: clientSessionId, - content: event.delta, - }); continue; } - if (event.type === "reasoning.updated") { + if (event.type === "message.part.delta" && event.properties.field === "text") { sawResponseActivity = true; - if (!firstReasoningLogged) { - firstReasoningLogged = true; - logDevelopmentDebug("first reasoning delta received", { - ...debugContext, - partId: event.partId, - elapsedMs: Math.max(0, Date.now() - requestStartedAt), - sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), - }); - } - const pending = reasoningDeltas.get(event.partId) ?? []; - if (event.delta) { - pending.push(event.delta); - reasoningDeltas.set(event.partId, pending); - } - const reasoningStatus = event.completed ? "completed" : "running"; - if (reasoningStatuses.get(event.partId) !== reasoningStatus) { - reasoningStatuses.set(event.partId, reasoningStatus); - logDevelopmentDebug("reasoning part status changed", { - ...debugContext, - partId: event.partId, - status: reasoningStatus, - chunkCount: pending.length, - elapsedMs: Math.max(0, Date.now() - requestStartedAt), - }); - } - emitProgress({ - id: event.partId, - phase: "planning", - status: event.completed ? "completed" : "running", - title: event.completed ? "分析规划完成" : "正在规划分析步骤", - detail: buildReasoningProgressDetail( - reasoningDeltas.get(event.partId) ?? [], - event.completed ? Date.now() : undefined, - ), - }); - continue; - } - - if (event.type === "tool.updated") { - sawResponseActivity = true; - const part = event.part; - if (!firstToolEventLogged) { - firstToolEventLogged = true; - logDevelopmentDebug("first tool event received", { - ...debugContext, - partId: part.id, - tool: part.tool, - status: part.state.status, - elapsedMs: Math.max(0, Date.now() - requestStartedAt), - sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), - }); - } - const toolParams = normalizeToolParams(part.state.input); - const reason = extractRequestReason(toolParams); - const isToolFinalState = - part.state.status === "completed" || part.state.status === "error"; - const nextToolStatus = String(part.state.status); - - if (toolStatuses.get(part.id) !== nextToolStatus) { - toolStatuses.set(part.id, nextToolStatus); - logDevelopmentDebug("tool part status changed", { - ...debugContext, - partId: part.id, - tool: part.tool, - status: nextToolStatus, - reason: reason || null, - inputKeys: Object.keys(toolParams).slice(0, 8), - error: - part.state.status === "error" ? (part.state.error ?? "unknown") : null, - elapsedMs: Math.max(0, Date.now() - requestStartedAt), - }); - } - - if (part.tool === "question" || part.tool === "request_user_input") { - // V2 emits the actionable form.created/question.asked event separately. - // Do not expose the tool callID as a request ID: it cannot settle a form. - continue; - } - - emitProgress({ - id: part.id, - phase: "tool", - status: normalizeToolStatus(part.state.status), - title: getToolProgressTitle(part.tool, part.state.status), - detail: buildToolProgressDetail( - part.tool, - part.state.status, - toolParams, - reason, - part.state.status === "error" ? part.state.error : undefined, - ), - }); - if ( - !emittedToolParts.has(part.id) && - (hasToolParams(toolParams) || isToolFinalState) - ) { - emittedToolParts.add(part.id); - toolCallCount += 1; - if (!reason) { - logger.warn( - { - tool: part.tool, - sessionId, - clientSessionId, - }, - "llm tool request missing reason", - ); + const partType = partTypes.get(event.properties.partID); + if (partType === "text") { + if (!firstTokenLogged) { + firstTokenLogged = true; + logDevelopmentDebug("first response token emitted", { + ...debugContext, + partId: event.properties.partID, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), + }); } - void writeLlmRequestAuditLog({ - kind: "tool", - sessionId, - clientSessionId, - traceId, - projectId, - target: part.tool, - reason, - reasonProvided: Boolean(reason), - payload: toolParams, - }).catch((error) => { - logger.warn({ err: error }, "failed to write tool audit log"); - }); - write("tool_call", { + emittedText = true; + write("token", { session_id: clientSessionId, - tool: part.tool, - params: toolParams, - reason, + content: event.properties.delta, }); + } else if (partType === "reasoning") { + if (!firstReasoningLogged) { + firstReasoningLogged = true; + logDevelopmentDebug("first reasoning delta received", { + ...debugContext, + partId: event.properties.partID, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), + }); + } + const pending = reasoningDeltas.get(event.properties.partID) ?? []; + pending.push(event.properties.delta); + reasoningDeltas.set(event.properties.partID, pending); + } else if (!partType) { + const pending = pendingPartTextDeltas.get(event.properties.partID) ?? []; + pending.push(event.properties.delta); + pendingPartTextDeltas.set(event.properties.partID, pending); } continue; } - if (event.type === "session.execution.started") { - emitProgress({ - id: "session-status", - phase: "session", - status: "running", - title: "Agent 正在处理", - detail: "OpenCode 已开始执行当前请求。", - }); + if (event.type === "message.part.updated") { + sawResponseActivity = true; + const part = event.properties.part; + partTypes.set(part.id, part.type); + if (part.type === "text") { + const pending = pendingPartTextDeltas.get(part.id) ?? []; + pendingPartTextDeltas.delete(part.id); + for (const content of pending) { + emittedText = true; + write("token", { + session_id: clientSessionId, + content, + }); + } + } else if (part.type === "reasoning") { + const pending = pendingPartTextDeltas.get(part.id) ?? []; + if (pending.length > 0) { + const existing = reasoningDeltas.get(part.id) ?? []; + reasoningDeltas.set(part.id, existing.concat(pending)); + } + pendingPartTextDeltas.delete(part.id); + const reasoningStatus = part.time.end ? "completed" : "running"; + if (reasoningStatuses.get(part.id) !== reasoningStatus) { + reasoningStatuses.set(part.id, reasoningStatus); + logDevelopmentDebug("reasoning part status changed", { + ...debugContext, + partId: part.id, + status: reasoningStatus, + chunkCount: (reasoningDeltas.get(part.id) ?? []).length, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + } + const reasoningDetail = buildReasoningProgressDetail( + reasoningDeltas.get(part.id) ?? [], + part.time.end, + ); + emitProgress({ + id: part.id, + phase: "planning", + status: part.time.end ? "completed" : "running", + title: part.time.end ? "分析规划完成" : "正在规划分析步骤", + detail: reasoningDetail, + }); + } + if (part.type === "tool") { + if (!firstToolEventLogged) { + firstToolEventLogged = true; + logDevelopmentDebug("first tool event received", { + ...debugContext, + partId: part.id, + tool: part.tool, + status: part.state.status, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt), + }); + } + const toolParams = normalizeToolParams(part.state.input); + const reason = extractRequestReason(toolParams); + const isToolFinalState = + part.state.status === "completed" || part.state.status === "error"; + const nextToolStatus = String(part.state.status); + + if (toolStatuses.get(part.id) !== nextToolStatus) { + toolStatuses.set(part.id, nextToolStatus); + logDevelopmentDebug("tool part status changed", { + ...debugContext, + partId: part.id, + tool: part.tool, + status: nextToolStatus, + reason: reason || null, + inputKeys: Object.keys(toolParams).slice(0, 8), + error: + part.state.status === "error" ? (part.state.error ?? "unknown") : null, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + } + + const questionToolPayload = normalizeQuestionToolPayload( + part, + toolParams, + clientSessionId, + ); + if (questionToolPayload) { + if (!emittedQuestionToolParts.has(part.id)) { + emittedQuestionToolParts.add(part.id); + emittedQuestionRequestIds.add(questionToolPayload.request_id); + logDevelopmentDebug("question tool request received", { + ...debugContext, + requestId: questionToolPayload.request_id, + tool: part.tool, + questionCount: questionToolPayload.questions.length, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + emitProgress({ + id: `question-${questionToolPayload.request_id}`, + phase: "question", + status: "running", + title: "等待用户补充信息", + detail: questionToolPayload.questions + .map((question) => question.question) + .join("\n"), + }); + write("question_request", questionToolPayload); + } + continue; + } + + emitProgress({ + id: part.id, + phase: "tool", + status: normalizeToolStatus(part.state.status), + title: getToolProgressTitle(part.tool, part.state.status), + detail: buildToolProgressDetail( + part.tool, + part.state.status, + toolParams, + reason, + part.state.status === "error" ? part.state.error : undefined, + ), + }); + if ( + !emittedToolParts.has(part.id) && + (hasToolParams(toolParams) || isToolFinalState) + ) { + emittedToolParts.add(part.id); + toolCallCount += 1; + if (!reason) { + logger.warn( + { + tool: part.tool, + sessionId: sessionId, + clientSessionId, + }, + "llm tool request missing reason", + ); + } + void writeLlmRequestAuditLog({ + kind: "tool", + sessionId: sessionId, + clientSessionId, + traceId, + projectId, + target: part.tool, + reason, + reasonProvided: Boolean(reason), + payload: toolParams, + }).catch((error) => { + logger.warn({ err: error }, "failed to write tool audit log"); + }); + write("tool_call", { + session_id: clientSessionId, + tool: part.tool, + params: toolParams, + reason, + }); + } + } continue; } - if (event.type === "session.execution.succeeded") { + if (event.type === "todo.updated") { + sawResponseActivity = true; + const todos = event.properties.todos as Array<{ + content: string; + status: string; + priority: string; + }>; + const normalizedTodos = todos.map((todo, index) => ({ + id: `todo-${index}-${todo.content.slice(0, 24)}`, + content: todo.content, + status: normalizeTodoStatus(todo.status), + priority: normalizeTodoPriority(todo.priority), + updated_at: Date.now(), + })); + const completed = todos.filter( + (todo) => todo.status === "completed", + ).length; emitProgress({ - id: "session-status", - phase: "session", - status: "completed", - title: "Agent 已完成处理", - detail: "当前请求已执行完成,正在整理并返回结果。", + id: "todo-progress", + phase: "planning", + status: completed === todos.length ? "completed" : "running", + title: `计划进度 ${completed}/${todos.length}`, + detail: todos + .map((todo) => `${todo.status}: ${todo.content}`) + .join("\n"), }); - done = true; + write("todo_update", { + session_id: clientSessionId, + todos: normalizedTodos, + created_at: Date.now(), + } satisfies TodoUpdatePayload); continue; } - if (event.type === "session.execution.failed" || event.type === "session.error") { + if (event.type === "session.error") { sawResponseActivity = true; - const runtimeError = event.error; logDevelopmentDebug("session error received", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), - error: runtimeError - ? getErrorMessage(runtimeError) + error: event.properties.error + ? getErrorMessage(event.properties.error) : "opencode session error", }); write("error", { session_id: clientSessionId, - message: runtimeError - ? getErrorMessage(runtimeError) + message: event.properties.error + ? getErrorMessage(event.properties.error) : "opencode session error", - detail: runtimeError?.name, - total_duration_ms: Math.max(0, Date.now() - requestStartedAt), - }); - failed = true; - done = true; - continue; - } - - if (event.type === "session.execution.interrupted") { - write("error", { - session_id: clientSessionId, - message: `OpenCode execution interrupted: ${event.reason}`, - detail: "SessionExecutionInterrupted", + detail: event.properties.error?.name, total_duration_ms: Math.max(0, Date.now() - requestStartedAt), }); failed = true; @@ -743,10 +880,27 @@ export const streamPromptResponse = async ({ } if (event.type === "session.idle") { - logDevelopmentDebug("ignoring legacy session.idle event", { + if (!sawResponseActivity) { + logDevelopmentDebug("ignoring session idle before response activity", { + ...debugContext, + elapsedMs: Math.max(0, Date.now() - requestStartedAt), + }); + continue; + } + logDevelopmentDebug("session idle received", { ...debugContext, + emittedText, + toolCallCount, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); + emitProgress({ + id: "session-status", + phase: "session", + status: "completed", + title: "Agent 已完成处理", + detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。", + }); + done = true; } } diff --git a/src/routes/chatStreamEvents.ts b/src/routes/chatStreamEvents.ts index bcab727..ecb44d1 100644 --- a/src/routes/chatStreamEvents.ts +++ b/src/routes/chatStreamEvents.ts @@ -1,9 +1,7 @@ +import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; + import { logger } from "../logger.js"; -import { - type QuestionAnswers, - type RuntimeEvent, - type RuntimePart, -} from "../runtime/opencode.js"; +import { type QuestionAnswers } from "../runtime/opencode.js"; export type PermissionRequestPayload = { session_id: string; @@ -141,16 +139,24 @@ export const extractRequestReason = (params: Record) => { return ""; }; -export const isSkillEvent = (event: RuntimeEvent) => - event.type === "skill.activated"; +export const isSkillEvent = (event: OpencodeEvent) => + event.type.toLowerCase().includes("skill"); -export const extractSkillAuditInfo = ( - event: Extract, -) => { - const payload = event.payload; +export const extractSkillAuditInfo = (event: OpencodeEvent) => { + const payload = isObjectRecord(event.properties) + ? (event.properties as Record) + : {}; + const candidateName = + typeof payload.skill === "string" + ? payload.skill + : typeof payload.skillName === "string" + ? payload.skillName + : typeof payload.name === "string" + ? payload.name + : event.type; const reason = extractRequestReason(payload); return { - name: event.name, + name: candidateName, reason, payload, }; @@ -159,50 +165,88 @@ export const extractSkillAuditInfo = ( export const hasToolParams = (params: Record) => Object.keys(params).length > 0; -export const isSessionEvent = (event: RuntimeEvent, sessionId: string) => - event.sessionId === sessionId; +export const isSessionEvent = (event: OpencodeEvent, sessionId: string) => + "properties" in event && + typeof event.properties === "object" && + event.properties !== null && + "sessionID" in event.properties && + event.properties.sessionID === sessionId; export const isPermissionAskedEvent = ( - event: RuntimeEvent, -): event is Extract => + event: OpencodeEvent, +): event is Extract => event.type === "permission.asked"; +export const isPermissionV2AskedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "permission.v2.asked"; + export const isPermissionRepliedEvent = ( - event: RuntimeEvent, -): event is Extract => + event: OpencodeEvent, +): event is Extract => event.type === "permission.replied"; +export const isPermissionV2RepliedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "permission.v2.replied"; + export const isQuestionAskedEvent = ( - event: RuntimeEvent, -): event is Extract => + event: OpencodeEvent, +): event is Extract => event.type === "question.asked"; +export const isQuestionV2AskedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "question.v2.asked"; + export const isQuestionRepliedEvent = ( - event: RuntimeEvent, -): event is Extract => + event: OpencodeEvent, +): event is Extract => event.type === "question.replied"; +export const isQuestionV2RepliedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "question.v2.replied"; + export const isQuestionRejectedEvent = ( - event: RuntimeEvent, -): event is Extract => + event: OpencodeEvent, +): event is Extract => event.type === "question.rejected"; +export const isQuestionV2RejectedEvent = ( + event: OpencodeEvent, +): event is Extract => + event.type === "question.v2.rejected"; + export const buildPermissionDetail = ( - event: Extract, + event: Extract, ) => { - const resources = event.request.resources.length - ? event.request.resources.join(", ") - : event.request.action; - return `需要用户确认权限:${event.request.action};资源:${resources}`; + const patterns = event.properties.patterns.length + ? event.properties.patterns.join(", ") + : event.properties.permission; + return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`; +}; + +export const buildPermissionV2Detail = ( + event: Extract, +) => { + const resources = event.properties.resources.length + ? event.properties.resources.join(", ") + : event.properties.action; + return `需要用户确认权限:${event.properties.action};资源:${resources}`; }; export const normalizeQuestionPayload = ( - event: Extract, + event: Extract, clientSessionId: string, ): QuestionRequestPayload => ({ session_id: clientSessionId, - request_id: event.request.id, - questions: event.request.questions.map((question) => ({ + request_id: event.properties.id, + questions: event.properties.questions.map((question) => ({ header: question.header, question: question.question, options: question.options.map((option) => ({ @@ -212,7 +256,7 @@ export const normalizeQuestionPayload = ( multiple: question.multiple, custom: question.custom, })), - tool: event.request.tool, + tool: event.properties.tool, created_at: Date.now(), }); @@ -225,6 +269,65 @@ export const normalizeQuestionAnswers = (answers: QuestionAnswers | undefined) = ) : []; +const questionToolNames = new Set(["question", "request_user_input"]); + +const normalizeQuestionOptions = (value: unknown): QuestionOptionPayload[] => + Array.isArray(value) + ? value.filter(isObjectRecord).map((option) => ({ + label: typeof option.label === "string" ? option.label : "", + description: + typeof option.description === "string" ? option.description : "", + })).filter((option) => option.label.trim().length > 0) + : []; + +const normalizeToolQuestionInfo = (value: unknown): QuestionInfoPayload | undefined => { + if (!isObjectRecord(value) || typeof value.question !== "string") { + return undefined; + } + const question = value.question.trim(); + if (!question) { + return undefined; + } + return { + header: + typeof value.header === "string" && value.header.trim() + ? value.header + : "补充信息", + question, + options: normalizeQuestionOptions(value.options), + multiple: typeof value.multiple === "boolean" ? value.multiple : undefined, + custom: typeof value.custom === "boolean" ? value.custom : undefined, + }; +}; + +export const normalizeQuestionToolPayload = ( + part: Extract, + params: Record, + clientSessionId: string, +): QuestionRequestPayload | undefined => { + if (!questionToolNames.has(part.tool)) { + return undefined; + } + const questions = Array.isArray(params.questions) + ? params.questions + .map(normalizeToolQuestionInfo) + .filter((question): question is QuestionInfoPayload => Boolean(question)) + : []; + if (questions.length === 0) { + return undefined; + } + return { + session_id: clientSessionId, + request_id: part.callID || part.id, + questions, + tool: { + messageID: part.messageID, + callID: part.callID, + }, + created_at: Date.now(), + }; +}; + export const normalizeTodoStatus = (status: string): TodoItemPayload["status"] => { if (status === "in_progress" || status === "completed" || status === "cancelled") { return status; @@ -241,12 +344,9 @@ export const normalizeTodoPriority = ( return undefined; }; -export const collectTextContent = (parts: RuntimePart[]) => +export const collectTextContent = (parts: Part[]) => parts - .filter( - (part): part is Extract => - part.type === "text", - ) + .filter((part): part is Extract => part.type === "text") .map((part) => part.text) .join(""); diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index ee00ec0..c2db38e 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -1,38 +1,32 @@ -import * as OpenCodePackage from "@opencode-ai/client"; -import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"; -import { Service } from "@opencode-ai/client/service"; -import { join, resolve } from "node:path"; +import { + createOpencode, + createOpencodeClient, + type OpencodeClient, +} from "@opencode-ai/sdk/v2"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; -const OPENCODE_V2_VERSION = "0.0.0-next-16741"; const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; -export const getEmbeddedServicePaths = (cwd: string, port: number) => { - const stateHome = resolve(cwd, "data", "opencode-service", String(port)); - return { - stateHome, - registrationFile: join(stateHome, "opencode", "service.json"), - }; -}; - const logDevelopmentDebug = ( message: string, metadata: Record, ) => { - if (isDevelopmentDebugLoggingEnabled) { - logger.info(metadata, message); + if (!isDevelopmentDebugLoggingEnabled) { + return; } + logger.info(metadata, message); }; export type RuntimeHealth = { healthy: boolean; version: string; - pid?: number; }; -export type RuntimeModelOverride = { +type RuntimeModelOverride = { providerID: string; modelID: string; }; @@ -40,145 +34,21 @@ export type RuntimeModelOverride = { export type PermissionReply = "once" | "always" | "reject"; export type QuestionAnswers = string[][]; -export type RuntimePart = - | { type: "text"; text: string } - | { type: "reasoning"; text: string }; - -export type RuntimeMessage = { +type RuntimeMessage = { info: { id: string; role: string; }; - parts: RuntimePart[]; }; -export type RuntimeError = { - name: string; - data?: { message?: string }; -}; - -export type RuntimeToolPart = { - id: string; - callID: string; - messageID: string; - tool: string; - state: { - status: "pending" | "running" | "completed" | "error"; - input: Record; - error?: string; - }; -}; - -type RuntimeQuestion = { - header: string; - question: string; - options: Array<{ label: string; description: string }>; - multiple?: boolean; - custom?: boolean; -}; - -type RuntimeQuestionTool = { messageID: string; callID: string }; - -export type RuntimeEvent = - | { type: "session.status"; sessionId: string; status: { type: "idle" | "busy" } | { type: "retry"; message: string } } - | { type: "session.execution.started"; sessionId: string } - | { type: "session.execution.succeeded"; sessionId: string } - | { type: "session.execution.failed"; sessionId: string; error: RuntimeError } - | { type: "session.execution.interrupted"; sessionId: string; reason: "user" | "shutdown" | "superseded" } - | { type: "permission.asked"; sessionId: string; request: { id: string; action: string; resources: string[]; save: string[]; metadata?: Record; tool?: RuntimeQuestionTool } } - | { type: "permission.replied"; sessionId: string; requestId: string; reply: PermissionReply } - | { type: "question.asked"; sessionId: string; request: { id: string; questions: RuntimeQuestion[]; tool?: RuntimeQuestionTool } } - | { type: "question.replied"; sessionId: string; requestId: string; answers: QuestionAnswers } - | { type: "question.rejected"; sessionId: string; requestId: string } - | { type: "text.delta"; sessionId: string; partId: string; delta: string } - | { type: "reasoning.updated"; sessionId: string; partId: string; delta?: string; completed: boolean } - | { type: "tool.updated"; sessionId: string; part: RuntimeToolPart } - | { type: "skill.activated"; sessionId: string; name: string; payload: Record } - | { type: "session.error"; sessionId?: string; error?: RuntimeError } - | { type: "session.idle"; sessionId: string }; - -type SessionMessageInfo = Awaited< - ReturnType ->["data"][number]; -type AssistantContent = Extract< - SessionMessageInfo, - { type: "assistant" } ->["content"][number]; -type RuntimeFormField = { - key: string; - title?: string; - description?: string; - type: "string" | "number" | "integer" | "boolean" | "multiselect" | "external"; - options?: Array<{ value: string; label: string; description?: string }>; - custom?: boolean; -}; - -const createClient = (options: { - baseUrl: string; - headers?: HeadersInit; -}): OpenCodeClient => - (OpenCodePackage as unknown as { - OpenCode: { make: (value: typeof options) => OpenCodeClient }; - }).OpenCode.make(options); - -const toModelRef = (model: RuntimeModelOverride) => ({ - providerID: model.providerID, - id: model.modelID, -}); - -const getConfiguredModel = (): RuntimeModelOverride => { - const [providerID, modelID] = config.OPENCODE_MODEL.split("/"); - if (!providerID || !modelID) { - throw new Error( - `invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`, - ); - } - return { providerID, modelID }; -}; - -const requireCompatibleHealth = (health: RuntimeHealth): RuntimeHealth => { - if (health.version !== OPENCODE_V2_VERSION) { - throw new Error( - `incompatible OpenCode service version: expected ${OPENCODE_V2_VERSION}, received ${health.version || "unknown"}`, - ); - } - return health; -}; - -const toRuntimeMessage = (message: SessionMessageInfo): RuntimeMessage => { - if (message.type === "user") { - return { - info: { id: message.id, role: "user" }, - parts: [{ type: "text", text: message.text }], - }; - } - if (message.type === "assistant") { - return { - info: { id: message.id, role: "assistant" }, - parts: message.content.flatMap((part: AssistantContent): RuntimePart[] => { - if (part.type === "text") { - return [{ type: "text", text: part.text }]; - } - if (part.type === "reasoning") { - return [{ type: "reasoning", text: part.text }]; - } - return []; - }), - }; - } - return { - info: { id: message.id, role: message.type }, - parts: [], - }; -}; +const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role; +const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id; export class OpencodeRuntimeAdapter { - private clientPromise: Promise | null = null; - private ownsEmbeddedService = false; - private embeddedServiceFile: string | undefined; - private readonly pendingForms = new Map(); + private clientPromise: Promise | null = null; + private closeServer: (() => void) | null = null; - async ensureClient(): Promise { + async ensureClient(): Promise { if (!this.clientPromise) { this.clientPromise = this.bootstrapClient().catch((error) => { this.clientPromise = null; @@ -190,101 +60,140 @@ export class OpencodeRuntimeAdapter { async health(): Promise { const client = await this.ensureClient(); - return requireCompatibleHealth(await client.health.get()); + const response = await client.global.health(); + return requireData(response.data, "global.health"); } async warmup(): Promise { const client = await this.ensureClient(); - requireCompatibleHealth(await client.health.get()); - const session = await client.session.create({ + const healthStartedAt = Date.now(); + const healthResponse = await client.global.health(); + const health = requireData(healthResponse.data, "global.health"); + logDevelopmentDebug("opencode warmup health check completed", { + elapsedMs: Math.max(0, Date.now() - healthStartedAt), + healthy: health.healthy, + version: health.version, + }); + + const sessionStartedAt = Date.now(); + const sessionResponse = await client.session.create({ title: "tjwater-agent-warmup", - agent: "instruction", - model: toModelRef(getConfiguredModel()), - location: { directory: process.cwd() }, + }); + const session = requireData(sessionResponse.data, "session.create"); + logDevelopmentDebug("opencode warmup session created", { + elapsedMs: Math.max(0, Date.now() - sessionStartedAt), + sessionId: session.id, }); try { - await Promise.all([ - client.model.list({ location: { directory: process.cwd() } }), - client.plugin.list({ location: { directory: process.cwd() } }), - ]); + const [provider, model] = config.OPENCODE_MODEL.split("/"); + if (!provider || !model) { + throw new Error( + `invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`, + ); + } + const toolsStartedAt = Date.now(); + const toolsResponse = await client.tool.list({ provider, model }); + const tools = requireData(toolsResponse.data, "tool.list"); + logDevelopmentDebug("opencode warmup tools loaded", { + elapsedMs: Math.max(0, Date.now() - toolsStartedAt), + model: config.OPENCODE_MODEL, + sessionId: session.id, + toolCount: tools.length, + }); } finally { - await client.session.remove({ sessionID: session.id }).catch((error: unknown) => { + const cleanupStartedAt = Date.now(); + let cleanupSucceeded = true; + await client.session.delete( + { sessionID: session.id }, + { throwOnError: true }, + ).catch((error) => { + cleanupSucceeded = false; logger.warn( { err: error, sessionId: session.id }, "failed to remove opencode warmup session", ); }); + logDevelopmentDebug("opencode warmup session cleanup completed", { + elapsedMs: Math.max(0, Date.now() - cleanupStartedAt), + sessionId: session.id, + succeeded: cleanupSucceeded, + }); } } - async createSession(title?: string): Promise<{ id: string }> { + async createSession(title?: string) { const client = await this.ensureClient(); - const session = await client.session.create({ + const response = await client.session.create({ title, - agent: "instruction", - model: toModelRef(getConfiguredModel()), - location: { directory: process.cwd() }, }); - if (!session.id) { - throw new Error("OpenCode V2 returned a session without an id"); - } - const id = session.id; - return { id }; + return requireData(response.data, "session.create"); } async sendPrompt(sessionId: string, text: string) { await this.prompt(sessionId, text); + // 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts, + // 所以这里紧跟一次 messages() 回读,给上层路由统一消费。 return this.messages(sessionId); } async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) { const client = await this.ensureClient(); const startedAt = Date.now(); - logDevelopmentDebug("dispatching opencode v2 session.prompt", { - sessionId, - model: model ?? null, - textChars: text.length, - }); - if (model) { - await client.session.switchModel({ - sessionID: sessionId, - model: toModelRef(model), - }); - } + logDevelopmentDebug( + "dispatching opencode session.prompt", + { + sessionId, + model: model ?? null, + textChars: text.length, + }, + ); await client.session.prompt({ sessionID: sessionId, - text, - resume: true, - }); - logDevelopmentDebug("opencode v2 session.prompt returned", { - sessionId, - elapsedMs: Math.max(0, Date.now() - startedAt), + model, + parts: [{ type: "text", text }], }); + logDevelopmentDebug( + "opencode session.prompt returned", + { + sessionId, + elapsedMs: Math.max(0, Date.now() - startedAt), + }, + ); } - async messages(sessionId: string, limit = 20): Promise { + async messages(sessionId: string, limit = 20) { const client = await this.ensureClient(); - const response = await client.message.list({ + const messages = await client.session.messages({ sessionID: sessionId, limit, - order: "asc", }); - return response.data.map(toRuntimeMessage); + return requireData(messages.data, "session.messages"); } async revertMessage(sessionId: string, messageId: string) { const client = await this.ensureClient(); - return client.session.revert.stage({ + const response = await client.session.revert({ sessionID: sessionId, messageID: messageId, - files: false, }); + return response.data; + } + + async removeMessage(sessionId: string, messageId: string) { + const client = await this.ensureClient(); + const response = await client.session.deleteMessage({ + sessionID: sessionId, + messageID: messageId, + }); + return response.data; } async revertToUserMessage(sessionId: string, options: { userOrdinal: number }) { const messages = await this.messages(sessionId, 80); - const userMessages = messages.filter((message) => message.info.role === "user"); + const userMessages = messages.filter( + (message) => getRuntimeMessageRole(message) === "user", + ); const targetUserMessage = userMessages[options.userOrdinal - 1]; if (!targetUserMessage) { @@ -298,506 +207,326 @@ export class OpencodeRuntimeAdapter { throw new Error("target user message not found to revert"); } - const client = await this.ensureClient(); - await client.session.revert.stage({ - sessionID: sessionId, - messageID: targetUserMessage.info.id, - files: false, - }); - await client.session.revert.commit({ sessionID: sessionId }); + const targetMessageId = getRuntimeMessageId(targetUserMessage); + const targetIndex = messages.findIndex( + (message) => getRuntimeMessageId(message) === targetMessageId, + ); + const messagesToRemove = targetIndex >= 0 ? messages.slice(targetIndex) : [targetUserMessage]; + + await this.revertMessage(sessionId, targetMessageId); + + for (const message of messagesToRemove.reverse()) { + const messageId = getRuntimeMessageId(message); + try { + await this.removeMessage(sessionId, messageId); + } catch (error) { + logger.warn( + { err: error, sessionId, messageId }, + "failed to remove reverted opencode message", + ); + } + } } async abortSession(sessionId: string) { const client = await this.ensureClient(); - await client.session.interrupt({ sessionID: sessionId }); + const response = await client.session.abort({ + sessionID: sessionId, + }); + return requireData(response.data, "session.abort"); } async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) { const client = await this.ensureClient(); - await client.session.wait( - { sessionID: sessionId }, - { signal: AbortSignal.timeout(timeoutMs) }, + const startedAt = Date.now(); + + while (Date.now() - startedAt < timeoutMs) { + const response = await client.session.status({}); + const statuses = requireData(response.data, "session.status"); + const status = statuses[sessionId]; + if (!status || status.type === "idle") { + return; + } + await delay(100); + } + + logger.warn( + { sessionId, timeoutMs }, + "timed out waiting for opencode session to become idle", ); } - async subscribeEvents(): Promise> { + async subscribeEvents() { const client = await this.ensureClient(); - return normalizeEventStream(client.event.subscribe(), this.pendingForms); + const response = await client.event.subscribe(); + return response.stream; } async replyPermission(options: { requestId: string; - sessionId: string; + sessionId?: string; reply: PermissionReply; message?: string; }) { const client = await this.ensureClient(); - await client.permission.reply({ - sessionID: options.sessionId, - requestID: options.requestId, - reply: options.reply, - message: options.message, - }); + if ("permission" in client && client.permission?.reply) { + const response = await client.permission.reply({ + requestID: options.requestId, + reply: options.reply, + message: options.message, + }); + return response.data; + } + + if ("permission" in client && client.permission?.respond && options.sessionId) { + const response = await client.permission.respond({ + sessionID: options.sessionId, + permissionID: options.requestId, + response: options.reply, + }); + return response.data; + } + + throw new Error("opencode permission reply API is unavailable"); } async replyQuestion(options: { requestId: string; - sessionId: string; + sessionId?: string; answers: QuestionAnswers; }) { const client = await this.ensureClient(); - if (isFormRequestId(options.requestId)) { - const fields = - this.pendingForms.get(options.requestId) ?? - ( - await client.form.get({ - sessionID: options.sessionId, - formID: options.requestId, - }) - ).fields as RuntimeFormField[]; - await client.form.reply({ - sessionID: options.sessionId, - formID: options.requestId, - answer: toFormAnswer(fields, options.answers), - }); - this.pendingForms.delete(options.requestId); - return; + if ("question" in client && client.question?.reply) { + try { + const response = await client.question.reply({ + requestID: options.requestId, + answers: options.answers, + }); + return response.data; + } catch (error) { + if (!options.sessionId) { + throw error; + } + } } - await client.question.reply({ - sessionID: options.sessionId, - requestID: options.requestId, - answers: options.answers, - }); + + const v2Question = (client as unknown as { + v2?: { + session?: { + question?: { + reply?: (parameters: { + sessionID: string; + requestID: string; + questionV2Reply: { answers: QuestionAnswers }; + }) => Promise<{ data: unknown }>; + }; + }; + }; + }).v2?.session?.question; + + if (v2Question?.reply && options.sessionId) { + const response = await v2Question.reply({ + sessionID: options.sessionId, + requestID: options.requestId, + questionV2Reply: { + answers: options.answers, + }, + }); + return response.data; + } + + throw new Error("opencode question reply API is unavailable"); } - async rejectQuestion(options: { requestId: string; sessionId: string }) { + async rejectQuestion(options: { + requestId: string; + sessionId?: string; + }) { const client = await this.ensureClient(); - if (isFormRequestId(options.requestId)) { - await client.form.cancel({ - sessionID: options.sessionId, - formID: options.requestId, - }); - this.pendingForms.delete(options.requestId); - return; + if ("question" in client && client.question?.reject) { + try { + const response = await client.question.reject({ + requestID: options.requestId, + }); + return response.data; + } catch (error) { + if (!options.sessionId) { + throw error; + } + } } - await client.question.reject({ - sessionID: options.sessionId, - requestID: options.requestId, - }); + + const v2Question = (client as unknown as { + v2?: { + session?: { + question?: { + reject?: (parameters: { + sessionID: string; + requestID: string; + }) => Promise<{ data: unknown }>; + }; + }; + }; + }).v2?.session?.question; + + if (v2Question?.reject && options.sessionId) { + const response = await v2Question.reject({ + sessionID: options.sessionId, + requestID: options.requestId, + }); + return response.data; + } + + throw new Error("opencode question reject API is unavailable"); } async dispose(): Promise { - if (this.ownsEmbeddedService && this.embeddedServiceFile) { - await Service.stop({ file: this.embeddedServiceFile }).catch((error) => { - logger.warn({ err: error }, "failed to stop opencode v2 service"); - }); - } - this.ownsEmbeddedService = false; - this.embeddedServiceFile = undefined; + this.closeServer?.(); + this.closeServer = null; this.clientPromise = null; } - private async bootstrapClient(): Promise { + private async bootstrapClient(): Promise { if (config.OPENCODE_MODE === "client") { logger.info( - { baseUrl: config.OPENCODE_CLIENT_BASE_URL, mode: config.OPENCODE_MODE }, - "connecting to opencode v2 service in client mode", + { + baseUrl: config.OPENCODE_CLIENT_BASE_URL, + mode: config.OPENCODE_MODE, + }, + "connecting to opencode server in client mode", ); - return createClient({ baseUrl: config.OPENCODE_CLIENT_BASE_URL! }); + return createOpencodeClient({ + baseUrl: config.OPENCODE_CLIENT_BASE_URL, + }); } + // embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里, + // 这样 .opencode/tools 下的自定义工具可以回调本服务。 process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`; process.env.TJWATER_AGENT_INTERNAL_TOKEN = - config.AGENT_INTERNAL_TOKEN ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + config.AGENT_INTERNAL_TOKEN ?? + process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? + ""; logger.info( - { model: config.OPENCODE_MODEL, mode: config.OPENCODE_MODE }, - "starting opencode v2 service in embedded mode", + { + hostname: config.OPENCODE_HOSTNAME, + port: config.OPENCODE_PORT, + model: config.OPENCODE_MODEL, + mode: config.OPENCODE_MODE, + }, + "starting opencode server in embedded mode", ); + const startedAt = Date.now(); - const { stateHome: serviceStateHome, registrationFile: serviceFile } = - getEmbeddedServicePaths(process.cwd(), config.PORT); - this.embeddedServiceFile = serviceFile; - let endpoint; - const previousStateHome = process.env.XDG_STATE_HOME; - process.env.XDG_STATE_HOME = serviceStateHome; + let runtime; try { - // The service inherits callback credentials from this Agent process. Stop an - // orphaned instance before launch so a restarted Agent never reuses stale env. - await Service.stop({ file: serviceFile }); - endpoint = await Service.ensure({ - file: serviceFile, - command: ["opencode2", "serve", "--service"], - version: OPENCODE_V2_VERSION, - onStart: () => { - this.ownsEmbeddedService = true; - }, + runtime = await createOpencode({ + hostname: config.OPENCODE_HOSTNAME, + port: config.OPENCODE_PORT, + timeout: config.OPENCODE_TIMEOUT_MS, + config: buildOpencodeConfig(), }); } catch (error) { - this.embeddedServiceFile = undefined; if (isMissingOpencodeCli(error)) { throw new Error( - "embedded mode requires the opencode2 CLI in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL", - { cause: error }, + "embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL", ); } throw error; - } finally { - if (previousStateHome === undefined) { - delete process.env.XDG_STATE_HOME; - } else { - process.env.XDG_STATE_HOME = previousStateHome; - } } logger.info( { - baseUrl: endpoint.url, elapsedMs: Math.max(0, Date.now() - startedAt), + hostname: config.OPENCODE_HOSTNAME, + port: config.OPENCODE_PORT, mode: config.OPENCODE_MODE, }, - "opencode v2 service started in embedded mode", + "opencode server started in embedded mode", ); - return createClient({ - baseUrl: endpoint.url, - headers: Service.headers(endpoint), - }); + + this.closeServer = () => { + runtime.server.close(); + }; + + return runtime.client; } } export const opencodeRuntime = new OpencodeRuntimeAdapter(); -function isMissingOpencodeCli(error: unknown): boolean { - const cause = error instanceof Error ? error.cause : undefined; +function buildOpencodeConfig(): Record { + return deepMerge( + deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), + { + model: config.OPENCODE_MODEL, + }, + ); +} + +function readProjectOpencodeConfig(): Record { + const path = resolve(process.cwd(), "opencode.json"); + if (!existsSync(path)) { + return {}; + } + return parseConfigJson(readFileSync(path, "utf8"), path); +} + +function readEnvOpencodeConfig(): Record { + const content = process.env.OPENCODE_CONFIG_CONTENT; + if (!content?.trim()) { + return {}; + } + return parseConfigJson(content, "OPENCODE_CONFIG_CONTENT"); +} + +function parseConfigJson(content: string, source: string): Record { + const parsed = JSON.parse(content) as unknown; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error(`${source} must contain a JSON object`); + } + return parsed as Record; +} + +function deepMerge( + left: Record, + right: Record, +): Record { + const next = { ...left }; + for (const [key, value] of Object.entries(right)) { + const existing = next[key]; + if (isPlainObject(existing) && isPlainObject(value)) { + next[key] = deepMerge(existing, value); + } else { + next[key] = value; + } + } + return next; +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException { return ( - (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") || - (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") + typeof error === "object" && + error !== null && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" ); } -async function* normalizeEventStream( - source: AsyncIterable, - pendingForms: Map, -): AsyncIterable { - const tools = new Map< - string, - { name: string; messageID: string; input: Record } - >(); - for await (const event of source) { - const normalized = normalizeEvent(event, tools, pendingForms); - if (normalized) { - yield normalized; - } +function requireData(data: T | undefined, operation: string): T { + if (data === undefined) { + throw new Error(`${operation} returned no data`); } + return data; } -function normalizeEvent( - event: OpenCodeEvent, - tools: Map }>, - pendingForms: Map, -): RuntimeEvent | undefined { - switch (event.type) { - case "session.status": - return { type: event.type, sessionId: event.data.sessionID, status: event.data.status }; - case "session.execution.started": - case "session.execution.succeeded": - return { type: event.type, sessionId: event.data.sessionID }; - case "session.execution.failed": - return { - type: event.type, - sessionId: event.data.sessionID, - error: toRuntimeError(event.data.error), - }; - case "session.execution.interrupted": - return { - type: event.type, - sessionId: event.data.sessionID, - reason: event.data.reason, - }; - case "permission.asked": - return { - type: event.type, - sessionId: event.data.sessionID, - request: { - id: event.data.id, - action: event.data.action, - resources: event.data.resources, - save: event.data.save ?? [], - metadata: event.data.metadata, - tool: event.data.source?.type === "tool" ? event.data.source : undefined, - }, - }; - case "permission.replied": - return { - type: event.type, - sessionId: event.data.sessionID, - requestId: event.data.requestID, - reply: event.data.reply, - }; - case "question.asked": - return { - type: event.type, - sessionId: event.data.sessionID, - request: { - id: event.data.id, - questions: event.data.questions, - tool: event.data.tool, - }, - }; - case "question.replied": - return { - type: event.type, - sessionId: event.data.sessionID, - requestId: event.data.requestID, - answers: event.data.answers, - }; - case "question.rejected": - return { - type: event.type, - sessionId: event.data.sessionID, - requestId: event.data.requestID, - }; - case "form.created": { - const form = event.data.form; - const fields = [...form.fields] as RuntimeFormField[]; - pendingForms.set(form.id, fields); - return { - type: "question.asked", - sessionId: form.sessionID, - request: { - id: form.id, - questions: fields.map((field) => toRuntimeQuestion(form.title, field)), - }, - }; - } - case "form.replied": { - const fields = pendingForms.get(event.data.id) ?? []; - pendingForms.delete(event.data.id); - return { - type: "question.replied", - sessionId: event.data.sessionID, - requestId: event.data.id, - answers: fields.map((field) => toQuestionAnswer(field, event.data.answer[field.key])), - }; - } - case "form.cancelled": - pendingForms.delete(event.data.id); - return { - type: "question.rejected", - sessionId: event.data.sessionID, - requestId: event.data.id, - }; - case "session.text.delta": - return { - type: "text.delta", - sessionId: event.data.sessionID, - partId: `text-${event.data.assistantMessageID}-${event.data.ordinal}`, - delta: event.data.delta, - }; - case "session.reasoning.delta": - return { - type: "reasoning.updated", - sessionId: event.data.sessionID, - partId: `reasoning-${event.data.assistantMessageID}-${event.data.ordinal}`, - delta: event.data.delta, - completed: false, - }; - case "session.reasoning.ended": - return { - type: "reasoning.updated", - sessionId: event.data.sessionID, - partId: `reasoning-${event.data.assistantMessageID}-${event.data.ordinal}`, - completed: true, - }; - case "session.tool.input.started": - tools.set(event.data.callID, { - name: event.data.name, - messageID: event.data.assistantMessageID, - input: {}, - }); - return undefined; - case "session.tool.called": { - const tracked = tools.get(event.data.callID) ?? { - name: "unknown", - messageID: event.data.assistantMessageID, - input: {}, - }; - tracked.input = event.data.input; - tools.set(event.data.callID, tracked); - return { - type: "tool.updated", - sessionId: event.data.sessionID, - part: toRuntimeToolPart(event.data.callID, tracked, "running"), - }; - } - case "session.tool.success": { - const tracked = tools.get(event.data.callID) ?? { - name: "unknown", - messageID: event.data.assistantMessageID, - input: {}, - }; - tools.delete(event.data.callID); - return { - type: "tool.updated", - sessionId: event.data.sessionID, - part: toRuntimeToolPart(event.data.callID, tracked, "completed"), - }; - } - case "session.tool.failed": { - const tracked = tools.get(event.data.callID) ?? { - name: "unknown", - messageID: event.data.assistantMessageID, - input: {}, - }; - tools.delete(event.data.callID); - return { - type: "tool.updated", - sessionId: event.data.sessionID, - part: toRuntimeToolPart( - event.data.callID, - tracked, - "error", - getStructuredErrorMessage(event.data.error), - ), - }; - } - case "session.skill.activated": - return { - type: "skill.activated", - sessionId: event.data.sessionID, - name: event.data.name, - payload: { id: event.data.id, name: event.data.name }, - }; - case "session.error": - return { - type: event.type, - sessionId: event.data.sessionID, - error: event.data.error ? toRuntimeError(event.data.error) : undefined, - }; - case "session.idle": - return { type: event.type, sessionId: event.data.sessionID }; - default: - return undefined; - } -} - -const isFormRequestId = (requestId: string) => requestId.startsWith("frm_"); - -function toRuntimeQuestion(formTitle: string, field: RuntimeFormField): RuntimeQuestion { - const options = - "options" in field && Array.isArray(field.options) - ? field.options.map((option) => ({ - label: option.label, - description: option.description ?? "", - })) - : field.type === "boolean" - ? [ - { label: "是", description: "" }, - { label: "否", description: "" }, - ] - : []; - return { - header: field.title?.trim() || formTitle, - question: field.description?.trim() || field.title?.trim() || formTitle, - options, - multiple: field.type === "multiselect", - custom: - field.type === "string" - ? (field.custom ?? options.length === 0) - : field.type !== "boolean" && field.type !== "multiselect", - }; -} - -function toFormAnswer( - fields: RuntimeFormField[], - answers: QuestionAnswers, -): Record> { - return Object.fromEntries( - fields.map((field, index) => [field.key, toFormValue(field, answers[index] ?? [])]), - ); -} - -function toFormValue( - field: RuntimeFormField, - answer: string[], -): string | number | boolean | ReadonlyArray { - const mapOption = (value: string) => - "options" in field && Array.isArray(field.options) - ? (field.options.find((option) => option.label === value)?.value ?? value) - : value; - if (field.type === "multiselect") { - return answer.map(mapOption); - } - if (field.type === "boolean") { - return ["true", "yes", "是", "1"].includes((answer[0] ?? "").trim().toLowerCase()); - } - if (field.type === "number" || field.type === "integer") { - const value = Number(answer[0]); - if (!Number.isFinite(value)) { - throw new Error(`invalid numeric answer for form field ${field.key}`); - } - return field.type === "integer" ? Math.trunc(value) : value; - } - return mapOption(answer[0] ?? ""); -} - -function toQuestionAnswer( - field: RuntimeFormField, - value: string | number | boolean | ReadonlyArray | undefined, -): string[] { - const values = Array.isArray(value) ? value : value === undefined ? [] : [value]; - return values.map((item) => { - const text = String(item); - if (field.type === "boolean") { - return item === true ? "是" : "否"; - } - return "options" in field && Array.isArray(field.options) - ? (field.options.find((option) => option.value === text)?.label ?? text) - : text; +function delay(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); }); } - -function toRuntimeToolPart( - callID: string, - tool: { name: string; messageID: string; input: Record }, - status: RuntimeToolPart["state"]["status"], - error?: string, -): RuntimeToolPart { - return { - id: callID, - callID, - messageID: tool.messageID, - tool: tool.name, - state: { status, input: tool.input, error }, - }; -} - -function toRuntimeError(error: unknown): RuntimeError { - if (typeof error === "object" && error !== null) { - const record = error as Record; - const name = - typeof record._tag === "string" - ? record._tag - : typeof record.name === "string" - ? record.name - : "OpenCodeError"; - return { name, data: { message: getStructuredErrorMessage(record) } }; - } - return { name: "OpenCodeError", data: { message: String(error) } }; -} - -function getStructuredErrorMessage(error: unknown): string { - if (typeof error === "object" && error !== null) { - const record = error as Record; - if (typeof record.message === "string") { - return record.message; - } - if (typeof record.data === "object" && record.data !== null) { - const message = (record.data as Record).message; - if (typeof message === "string") { - return message; - } - } - } - return String(error); -} diff --git a/src/server.ts b/src/server.ts index 07fb18e..3bda2f1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -54,6 +54,8 @@ app.get("/health", async (_req, res) => { const runtime = await opencodeRuntime.health(); res.json({ ok: true, + ready: true, + warmed_up: true, runtime, sessions: sessionBridge.count(), }); @@ -61,6 +63,8 @@ app.get("/health", async (_req, res) => { const detail = error instanceof Error ? error.message : String(error); res.status(503).json({ ok: false, + ready: false, + warmed_up: true, message: "opencode runtime unavailable", detail, sessions: sessionBridge.count(), @@ -520,7 +524,12 @@ resultReferenceStore.startCleanupLoop(); const server = app.listen(config.PORT, config.HOST, () => { logger.info( - { host: config.HOST, port: config.PORT }, + { + host: config.HOST, + port: config.PORT, + ready: true, + warmedUp: true, + }, "TJWaterAgent listening", ); }); @@ -529,7 +538,7 @@ const shutdown = async () => { logger.info("shutting down TJWaterAgent"); server.close(); resultReferenceStore.stopCleanupLoop(); - // 同步关闭 embedded OpenCode V2 service,避免本服务退出后留下孤儿进程。 + // 同步关闭 embedded opencode server,避免本服务退出后留下孤儿进程。 await opencodeRuntime.dispose(); }; diff --git a/tests/opencode/skillManagerTool.test.ts b/tests/opencode/skillManagerTool.test.ts index 1a67944..a453f5d 100644 --- a/tests/opencode/skillManagerTool.test.ts +++ b/tests/opencode/skillManagerTool.test.ts @@ -7,9 +7,6 @@ import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js"; import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js"; import { SkillStore } from "../../src/skills/store.js"; -const toolContent = async (result: Promise<{ content?: string }>) => - (await result).content ?? ""; - describe("skill_manager tool", () => { let tempDir: string; let skillStore: SkillStore; @@ -64,7 +61,7 @@ describe("skill_manager tool", () => { ); const writeResult = JSON.parse( - await toolContent(tool.execute( + await tool.execute( { action: "write_skill", content: skillDocument("# Pressure Review"), @@ -72,7 +69,7 @@ describe("skill_manager tool", () => { skill_path: "workflow/pressure-review", }, toolContext, - )), + ) as string, ); expect(writeResult.decision).toBe("accepted"); await expect(readFile(writeResult.target, "utf8")).resolves.toContain( @@ -80,7 +77,7 @@ describe("skill_manager tool", () => { ); const updateResult = JSON.parse( - await toolContent(tool.execute( + await tool.execute( { action: "write_skill", content: skillDocument("# Updated Pressure Review"), @@ -88,7 +85,7 @@ describe("skill_manager tool", () => { skill_path: "workflow/pressure-review", }, toolContext, - )), + ) as string, ); expect(updateResult.decision).toBe("accepted"); await expect(readFile(updateResult.target, "utf8")).resolves.toContain( @@ -96,14 +93,14 @@ describe("skill_manager tool", () => { ); const removeResult = JSON.parse( - await toolContent(tool.execute( + await tool.execute( { action: "remove_skill", reason: "workflow is obsolete", skill_path: "workflow/pressure-review", }, toolContext, - )), + ) as string, ); expect(removeResult.decision).toBe("accepted"); await expect(readFile(removeResult.target, "utf8")).rejects.toThrow(); @@ -117,7 +114,7 @@ describe("skill_manager tool", () => { ); const writeResult = JSON.parse( - await toolContent(tool.execute( + await tool.execute( { action: "write_skill", content: [ @@ -132,7 +129,7 @@ describe("skill_manager tool", () => { skill_path: "__root__", }, toolContext, - )), + ) as string, ); expect(writeResult.decision).toBe("accepted"); diff --git a/tests/routes/chatStream.test.ts b/tests/routes/chatStream.test.ts index 73ae420..6a39e84 100644 --- a/tests/routes/chatStream.test.ts +++ b/tests/routes/chatStream.test.ts @@ -21,18 +21,20 @@ describe("streamPromptResponse", () => { createEventStream([ { type: "permission.asked", - sessionId: "runtime-session-1", - request: { + properties: { id: "perm-1", - action: "shell", - resources: ["rm *"], + sessionID: "runtime-session-1", + permission: "bash", + patterns: ["rm *"], metadata: { command: "rm tmp.txt" }, - save: ["rm *"], + always: ["rm *"], }, }, { - type: "session.execution.succeeded", - sessionId: "runtime-session-1", + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, }, ]), prompt: async () => undefined, @@ -52,7 +54,7 @@ describe("streamPromptResponse", () => { expect(permissionEvent?.data).toMatchObject({ session_id: "client-session-1", request_id: "perm-1", - permission: "shell", + permission: "bash", patterns: ["rm *"], target: "rm tmp.txt", always: ["rm *"], @@ -66,18 +68,20 @@ describe("streamPromptResponse", () => { createEventStream([ { type: "permission.asked", - sessionId: "runtime-session-1", - request: { + properties: { id: "perm-1", - action: "shell", - resources: ["npm test"], + sessionID: "runtime-session-1", + permission: "bash", + patterns: ["npm test"], metadata: { command: "npm test" }, - save: ["npm test"], + always: ["npm test"], }, }, { - type: "session.execution.succeeded", - sessionId: "runtime-session-1", + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, }, ]), prompt: async () => undefined, @@ -112,15 +116,15 @@ describe("streamPromptResponse", () => { }); }); - it("forwards external-directory V2 permission requests as SSE payloads", async () => { + it("forwards opencode v2 permission requests as SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { - type: "permission.asked", - sessionId: "runtime-session-1", - request: { + type: "permission.v2.asked", + properties: { id: "perm-v2-1", + sessionID: "runtime-session-1", action: "external_directory", resources: ["/tmp"], save: ["/tmp"], @@ -128,8 +132,10 @@ describe("streamPromptResponse", () => { }, }, { - type: "session.execution.succeeded", - sessionId: "runtime-session-1", + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, }, ]), prompt: async () => undefined, @@ -162,9 +168,9 @@ describe("streamPromptResponse", () => { createEventStream([ { type: "question.asked", - sessionId: "runtime-session-1", - request: { + properties: { id: "question-1", + sessionID: "runtime-session-1", questions: [ { header: "范围", @@ -178,13 +184,17 @@ describe("streamPromptResponse", () => { }, { type: "question.replied", - sessionId: "runtime-session-1", - requestId: "question-1", - answers: [["城区", "补充说明"]], + properties: { + sessionID: "runtime-session-1", + requestID: "question-1", + answers: [["城区", "补充说明"]], + }, }, { - type: "session.execution.succeeded", - sessionId: "runtime-session-1", + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, }, ]), prompt: async () => undefined, @@ -220,16 +230,19 @@ describe("streamPromptResponse", () => { }); }); - it("waits for the Form event instead of exposing the question tool call ID", async () => { + it("converts question tool parts into question request SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { - type: "tool.updated", - sessionId: "runtime-session-1", - part: { + type: "message.part.updated", + properties: { + sessionID: "runtime-session-1", + part: { id: "tool-part-1", + sessionID: "runtime-session-1", messageID: "message-1", + type: "tool", callID: "call-1", tool: "question", state: { @@ -248,31 +261,17 @@ describe("streamPromptResponse", () => { }, ], }, + time: { start: Date.now() }, }, }, - }, - { - type: "question.asked", - sessionId: "runtime-session-1", - request: { - id: "frm_1", - questions: [ - { - header: "测试问题", - question: "你觉得这个 question 工具好用吗?", - options: [ - { - label: "非常好用", - description: "交互清晰,选项方便", - }, - ], - }, - ], + time: Date.now(), }, }, { - type: "session.execution.succeeded", - sessionId: "runtime-session-1", + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, }, ]), prompt: async () => undefined, @@ -290,7 +289,7 @@ describe("streamPromptResponse", () => { expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({ session_id: "client-session-1", - request_id: "frm_1", + request_id: "call-1", questions: [ { header: "测试问题", @@ -303,12 +302,11 @@ describe("streamPromptResponse", () => { ], }, ], + tool: { + messageID: "message-1", + callID: "call-1", + }, }); - expect( - events.some( - (item) => item.event === "question_request" && item.data.request_id === "call-1", - ), - ).toBe(false); expect( events.some( (item) => item.event === "tool_call" && item.data.tool === "question", @@ -316,32 +314,25 @@ describe("streamPromptResponse", () => { ).toBe(false); }); - it("forwards normalized V2 text and reasoning deltas", async () => { + it("forwards todo updates as structured SSE payloads and progress", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { - type: "reasoning.updated", - sessionId: "runtime-session-1", - partId: "reasoning-1", - delta: "分析中", - completed: false, + type: "todo.updated", + properties: { + sessionID: "runtime-session-1", + todos: [ + { content: "分析水位", status: "completed", priority: "high" }, + { content: "生成建议", status: "in_progress", priority: "medium" }, + ], + }, }, { - type: "reasoning.updated", - sessionId: "runtime-session-1", - partId: "reasoning-1", - completed: true, - }, - { - type: "text.delta", - sessionId: "runtime-session-1", - partId: "text-1", - delta: "完成", - }, - { - type: "session.execution.succeeded", - sessionId: "runtime-session-1", + type: "session.idle", + properties: { + sessionID: "runtime-session-1", + }, }, ]), prompt: async () => undefined, @@ -357,83 +348,30 @@ describe("streamPromptResponse", () => { write: (event, data) => events.push({ event, data }), }); - expect(events.find((item) => item.event === "token")?.data).toEqual({ - session_id: "client-session-1", - content: "完成", - }); expect( events.find( - (item) => - item.event === "progress" && - item.data.id === "reasoning-1" && - item.data.status === "completed", + (item) => item.event === "progress" && item.data.id === "todo-progress", )?.data, ).toMatchObject({ + id: "todo-progress", phase: "planning", - title: "分析规划完成", + title: "计划进度 1/2", + }); + expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({ + session_id: "client-session-1", + todos: [ + expect.objectContaining({ + content: "分析水位", + status: "completed", + priority: "high", + }), + expect.objectContaining({ + content: "生成建议", + status: "in_progress", + priority: "medium", + }), + ], }); }); - it("retains the first runtime event when prompt resolves before delivery", async () => { - const runtime = { - subscribeEvents: async () => ({ - async *[Symbol.asyncIterator]() { - await Promise.resolve(); - yield { - type: "text.delta", - sessionId: "runtime-session-1", - partId: "text-1", - delta: "首个事件", - }; - yield { - type: "session.execution.succeeded", - sessionId: "runtime-session-1", - }; - }, - }), - prompt: async () => undefined, - messages: async () => [], - } as unknown as OpencodeRuntimeAdapter; - const events: Array<{ event: string; data: Record }> = []; - - await streamPromptResponse({ - runtime, - sessionId: "runtime-session-1", - clientSessionId: "client-session-1", - message: "race", - write: (event, data) => events.push({ event, data }), - }); - - expect(events.find((item) => item.event === "token")?.data.content).toBe("首个事件"); - expect(events.find((item) => item.event === "done")?.data.session_id).toBe( - "client-session-1", - ); - }); - - it("terminates the stream when V2 reports execution failure", async () => { - const runtime = { - subscribeEvents: async () => - createEventStream([ - { - type: "session.execution.failed", - sessionId: "runtime-session-1", - error: { name: "ExecutionError", data: { message: "执行失败" } }, - }, - ]), - prompt: async () => undefined, - messages: async () => [], - } as unknown as OpencodeRuntimeAdapter; - const events: Array<{ event: string; data: Record }> = []; - - const result = await streamPromptResponse({ - runtime, - sessionId: "runtime-session-1", - clientSessionId: "client-session-1", - message: "fail", - write: (event, data) => events.push({ event, data }), - }); - - expect(events.find((item) => item.event === "error")?.data.message).toBe("执行失败"); - expect(result.failed).toBe(true); - }); }); diff --git a/tests/runtime/opencode.test.ts b/tests/runtime/opencode.test.ts index 042df8e..7fb4548 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -1,57 +1,34 @@ import { describe, expect, it } from "bun:test"; -import { type OpenCodeClient } from "@opencode-ai/client"; +import { type OpencodeClient } from "@opencode-ai/sdk/v2"; -import { - getEmbeddedServicePaths, - OpencodeRuntimeAdapter, -} from "../../src/runtime/opencode.js"; - -describe("getEmbeddedServicePaths", () => { - it("isolates service registrations by workspace and Agent port", () => { - const internal = getEmbeddedServicePaths("/srv/tjwater-agent", 8787); - const customer = getEmbeddedServicePaths("/srv/tjwater-agent-customer", 8787); - const secondPort = getEmbeddedServicePaths("/srv/tjwater-agent", 8788); - - expect(internal.registrationFile).not.toBe(customer.registrationFile); - expect(internal.registrationFile).not.toBe(secondPort.registrationFile); - expect(internal.registrationFile).toEndWith( - "/data/opencode-service/8787/opencode/service.json", - ); - }); -}); +import { config } from "../../src/config.js"; +import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; const createRuntimeAdapter = ( messages: unknown[], - calls: { staged: string[]; committed: string[] } = { - staged: [], - committed: [], - }, + calls: { + reverted: string[]; + removed: string[]; + } = { reverted: [], removed: [] }, ) => Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { messages: async () => messages, - ensureClient: async () => - ({ - session: { - revert: { - stage: async ({ messageID }: { messageID: string }) => { - calls.staged.push(messageID); - }, - commit: async ({ sessionID }: { sessionID: string }) => { - calls.committed.push(sessionID); - }, - }, - }, - }) as unknown as OpenCodeClient, + revertMessage: async (_sessionId: string, messageId: string) => { + calls.reverted.push(messageId); + }, + removeMessage: async (_sessionId: string, messageId: string) => { + calls.removed.push(messageId); + }, }) as OpencodeRuntimeAdapter; describe("OpencodeRuntimeAdapter.revertToUserMessage", () => { it("skips reverting the first user message when the runtime session is empty", async () => { - const calls = { staged: [] as string[], committed: [] as string[] }; + const calls = { reverted: [] as string[], removed: [] as string[] }; const runtime = createRuntimeAdapter([], calls); await runtime.revertToUserMessage("session-1", { userOrdinal: 1 }); - expect(calls).toEqual({ staged: [], committed: [] }); + expect(calls).toEqual({ reverted: [], removed: [] }); }); it("keeps ordinal mismatches visible when runtime messages exist", async () => { @@ -65,8 +42,8 @@ describe("OpencodeRuntimeAdapter.revertToUserMessage", () => { ).rejects.toThrow("target user message not found to revert"); }); - it("stages and commits the V2 revert at the target user message", async () => { - const calls = { staged: [] as string[], committed: [] as string[] }; + it("reverts and removes messages from the target user message onward", async () => { + const calls = { reverted: [] as string[], removed: [] as string[] }; const runtime = createRuntimeAdapter( [ { info: { id: "user-1", role: "user" } }, @@ -80,8 +57,8 @@ describe("OpencodeRuntimeAdapter.revertToUserMessage", () => { await runtime.revertToUserMessage("session-1", { userOrdinal: 2 }); expect(calls).toEqual({ - staged: ["user-2"], - committed: ["session-1"], + reverted: ["user-2"], + removed: ["assistant-2", "user-2"], }); }); }); @@ -90,10 +67,11 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => { it("retries bootstrap after a failed startup attempt", async () => { let attempts = 0; const client = { - health: { get: async () => ({ healthy: true }) }, - } as unknown as OpenCodeClient; + global: { health: async () => ({ data: { healthy: true } }) }, + } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { clientPromise: null, + closeServer: null, bootstrapClient: async () => { attempts += 1; if (attempts === 1) { @@ -110,66 +88,45 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => { }); describe("OpencodeRuntimeAdapter.warmup", () => { - it("rejects a service that is not the pinned V2 release", async () => { - const client = { - health: { - get: async () => ({ healthy: true, version: "1.18.12" }), - }, - } as unknown as OpenCodeClient; - const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { - clientPromise: null, - ensureClient: async () => client, - }) as OpencodeRuntimeAdapter; - - await expect(runtime.warmup()).rejects.toThrow( - "incompatible OpenCode service version", - ); - }); - - it("checks V2 health, catalogs and plugins, then removes the probe session", async () => { + it("initializes the project session and model tools before reporting ready", async () => { const calls: string[] = []; const client = { - health: { - get: async () => { - calls.push("health.get"); - return { healthy: true, version: "0.0.0-next-16741" }; + global: { + health: async () => { + calls.push("health"); + return { data: { healthy: true, version: "test" } }; }, }, session: { create: async () => { calls.push("session.create"); - return { id: "warmup-session" }; + return { data: { id: "warmup-session" } }; }, - remove: async ({ sessionID }: { sessionID: string }) => { - calls.push(`session.remove:${sessionID}`); + delete: async ({ sessionID }: { sessionID: string }) => { + calls.push(`session.delete:${sessionID}`); + return { data: true }; }, }, - model: { - list: async () => { - calls.push("model.list"); + tool: { + list: async (model: { provider: string; model: string }) => { + calls.push(`tool.list:${model.provider}/${model.model}`); return { data: [] }; }, }, - plugin: { - list: async () => { - calls.push("plugin.list"); - return []; - }, - }, - } as unknown as OpenCodeClient; + } as unknown as OpencodeClient; const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { clientPromise: null, + closeServer: null, ensureClient: async () => client, }) as OpencodeRuntimeAdapter; await runtime.warmup(); expect(calls).toEqual([ - "health.get", + "health", "session.create", - "model.list", - "plugin.list", - "session.remove:warmup-session", + `tool.list:${config.OPENCODE_MODEL}`, + "session.delete:warmup-session", ]); }); }); diff --git a/tests/runtime/opencodeInteraction.test.ts b/tests/runtime/opencodeInteraction.test.ts deleted file mode 100644 index 9b8960e..0000000 --- a/tests/runtime/opencodeInteraction.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { type OpenCodeClient } from "@opencode-ai/client"; - -import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; - -const createRuntimeAdapter = (client: unknown) => - Object.assign(new OpencodeRuntimeAdapter(), { - clientPromise: Promise.resolve(client as OpenCodeClient), - }) as OpencodeRuntimeAdapter; - -const createEventStream = (events: unknown[]) => ({ - async *[Symbol.asyncIterator]() { - for (const event of events) { - yield event; - } - }, -}); - -describe("OpencodeRuntimeAdapter V2 interaction responses", () => { - it("replies through the top-level V2 permission API", async () => { - const runtime = createRuntimeAdapter({ - permission: { - reply: async (parameters: Record) => { - expect(parameters).toEqual({ - sessionID: "session-1", - requestID: "request-1", - reply: "once", - message: "approved", - }); - }, - }, - }); - - await expect( - runtime.replyPermission({ - requestId: "request-1", - sessionId: "session-1", - reply: "once", - message: "approved", - }), - ).resolves.toBeUndefined(); - }); - - it("replies through the top-level V2 question API", async () => { - const runtime = createRuntimeAdapter({ - question: { - reply: async (parameters: Record) => { - expect(parameters).toEqual({ - sessionID: "session-1", - requestID: "request-1", - answers: [["A"]], - }); - }, - }, - }); - - await expect( - runtime.replyQuestion({ - requestId: "request-1", - sessionId: "session-1", - answers: [["A"]], - }), - ).resolves.toBeUndefined(); - }); - - it("rejects through the top-level V2 question API", async () => { - const runtime = createRuntimeAdapter({ - question: { - reject: async (parameters: Record) => { - expect(parameters).toEqual({ - sessionID: "session-1", - requestID: "request-1", - }); - }, - }, - }); - - await expect( - runtime.rejectQuestion({ - requestId: "request-1", - sessionId: "session-1", - }), - ).resolves.toBeUndefined(); - }); - - it("maps frontend question answers to the V2 Form API", async () => { - const runtime = createRuntimeAdapter({ - form: { - get: async (parameters: Record) => { - expect(parameters).toEqual({ sessionID: "session-1", formID: "frm_1" }); - return { - fields: [ - { - key: "scope", - type: "string", - title: "范围", - options: [{ value: "urban", label: "城区" }], - }, - { key: "confirmed", type: "boolean", title: "确认" }, - ], - }; - }, - reply: async (parameters: Record) => { - expect(parameters).toEqual({ - sessionID: "session-1", - formID: "frm_1", - answer: { scope: "urban", confirmed: true }, - }); - }, - }, - }); - - await expect( - runtime.replyQuestion({ - requestId: "frm_1", - sessionId: "session-1", - answers: [["城区"], ["是"]], - }), - ).resolves.toBeUndefined(); - }); - - it("cancels V2 Form requests by form ID", async () => { - const runtime = createRuntimeAdapter({ - form: { - cancel: async (parameters: Record) => { - expect(parameters).toEqual({ sessionID: "session-1", formID: "frm_1" }); - }, - }, - }); - - await expect( - runtime.rejectQuestion({ requestId: "frm_1", sessionId: "session-1" }), - ).resolves.toBeUndefined(); - }); - - it("normalizes V2 Form and execution events for the chat stream", async () => { - const runtime = createRuntimeAdapter({ - event: { - subscribe: () => - createEventStream([ - { - type: "form.created", - data: { - form: { - id: "frm_1", - sessionID: "session-1", - title: "分析参数", - fields: [ - { - key: "scope", - type: "string", - title: "范围", - description: "选择分析范围", - options: [{ value: "urban", label: "城区" }], - }, - ], - }, - }, - }, - { - type: "session.execution.succeeded", - data: { sessionID: "session-1" }, - }, - ]), - }, - }); - const events = []; - - for await (const event of await runtime.subscribeEvents()) { - events.push(event); - } - - expect(events).toEqual([ - { - type: "question.asked", - sessionId: "session-1", - request: { - id: "frm_1", - questions: [ - { - header: "范围", - question: "选择分析范围", - options: [{ label: "城区", description: "" }], - multiple: false, - custom: false, - }, - ], - }, - }, - { type: "session.execution.succeeded", sessionId: "session-1" }, - ]); - }); -});