diff --git a/.opencode/bun.lock b/.opencode/bun.lock index 53e84d6..b979275 100644 --- a/.opencode/bun.lock +++ b/.opencode/bun.lock @@ -4,7 +4,8 @@ "workspaces": { "": { "dependencies": { - "@opencode-ai/plugin": "^1.16.2", + "@opencode-ai/plugin": "0.0.0-next-16741", + "zod": "4.1.8", }, "devDependencies": { "@types/node": "^24.7.2", @@ -13,6 +14,16 @@ }, }, "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=="], @@ -25,58 +36,212 @@ "@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/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="], + "@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/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], + "@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=="], "@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=="], - "effect": ["effect@4.0.0-beta.74", "", { "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-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], - "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], + "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=="], "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=="], - "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="], + "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-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.7", "", {}, "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA=="], + "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=="], "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=="], - "toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="], + "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=="], "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.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="], + "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=="], "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 1cc25dc..66983d0 100644 --- a/.opencode/package.json +++ b/.opencode/package.json @@ -4,7 +4,8 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "@opencode-ai/plugin": "^1.16.2" + "@opencode-ai/plugin": "0.0.0-next-16741", + "zod": "4.1.8" }, "devDependencies": { "@types/node": "^24.7.2", diff --git a/.opencode/plugins/tjwater.ts b/.opencode/plugins/tjwater.ts new file mode 100644 index 0000000..3db089a --- /dev/null +++ b/.opencode/plugins/tjwater.ts @@ -0,0 +1,44 @@ +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 95e183b..b98c42f 100644 --- a/.opencode/tools/apply_layer_style.ts +++ b/.opencode/tools/apply_layer_style.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; export default tool({ description: diff --git a/.opencode/tools/defineTool.ts b/.opencode/tools/defineTool.ts new file mode 100644 index 0000000..d3bc64d --- /dev/null +++ b/.opencode/tools/defineTool.ts @@ -0,0 +1,31 @@ +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 d113ad5..67c1a2d 100644 --- a/.opencode/tools/geocode.ts +++ b/.opencode/tools/geocode.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; 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 1ff3b49..6160bee 100644 --- a/.opencode/tools/locate_features.ts +++ b/.opencode/tools/locate_features.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; export default tool({ description: "在前端地图上定位并高亮指定的管网要素。", diff --git a/.opencode/tools/memory_manager.ts b/.opencode/tools/memory_manager.ts index 6de8faf..be536d6 100644 --- a/.opencode/tools/memory_manager.ts +++ b/.opencode/tools/memory_manager.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; import { MemoryStore } from "../../src/memory/store.js"; import { getRuntimeSessionContext, diff --git a/.opencode/tools/render_junctions.ts b/.opencode/tools/render_junctions.ts index 49043f2..6d44c7e 100644 --- a/.opencode/tools/render_junctions.ts +++ b/.opencode/tools/render_junctions.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; export default tool({ description: diff --git a/.opencode/tools/session_search.ts b/.opencode/tools/session_search.ts index 50dcea9..b52de47 100644 --- a/.opencode/tools/session_search.ts +++ b/.opencode/tools/session_search.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; 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 e133305..3aa789d 100644 --- a/.opencode/tools/show_chart.ts +++ b/.opencode/tools/show_chart.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; export default tool({ description: diff --git a/.opencode/tools/skill_manager.ts b/.opencode/tools/skill_manager.ts index 82cc8e7..0aa720a 100644 --- a/.opencode/tools/skill_manager.ts +++ b/.opencode/tools/skill_manager.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; 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 e9fab74..5997337 100644 --- a/.opencode/tools/store_render_ref.ts +++ b/.opencode/tools/store_render_ref.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; 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 e72a0f6..9c7f5d0 100644 --- a/.opencode/tools/tjwater_cli.ts +++ b/.opencode/tools/tjwater_cli.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; 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 29c68a9..83aaebf 100644 --- a/.opencode/tools/view_history.ts +++ b/.opencode/tools/view_history.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; export default tool({ description: "为选定的管网要素打开前端的历史记录或计算结果面板。", diff --git a/.opencode/tools/view_scada.ts b/.opencode/tools/view_scada.ts index ad05355..b930f2d 100644 --- a/.opencode/tools/view_scada.ts +++ b/.opencode/tools/view_scada.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; export default tool({ description: "打开前端的 SCADA 监测数据历史面板。", diff --git a/.opencode/tools/web_search.ts b/.opencode/tools/web_search.ts index fd64c2a..6a92139 100644 --- a/.opencode/tools/web_search.ts +++ b/.opencode/tools/web_search.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; 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 986533b..865e227 100644 --- a/.opencode/tools/zoom_to_map.ts +++ b/.opencode/tools/zoom_to_map.ts @@ -1,4 +1,4 @@ -import { tool } from "@opencode-ai/plugin"; +import { tool } from "./defineTool.js"; export default tool({ description: diff --git a/Dockerfile b/Dockerfile index 0db1fdb..3770b9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,11 @@ -FROM smanx/opencode:latest AS base +FROM ubuntu:24.04 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:$PATH" +ENV PATH="$VIRTUAL_ENV/bin:/root/.local/bin:/root/.bun/bin:$PATH" ENV PIP_INDEX_URL=${PYPI_INDEX_URL} ENV PIP_TRUSTED_HOST=${PYPI_TRUSTED_HOST} ENV UV_INDEX_URL=${PYPI_INDEX_URL} @@ -16,6 +17,7 @@ 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 \ @@ -25,6 +27,8 @@ 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 && \ diff --git a/README.md b/README.md index c57a45f..041a9b5 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,10 @@ ## 主要能力 - 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。 -- 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server。 +- 使用 OpenCode 2.0 beta client;支持 embedded `opencode2` service,也可连接外部 V2 service。 - 管理前端 `session_id` 与 OpenCode session 的映射。 - 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。 -- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。 +- 通过 `.opencode/plugins` 注册 `.opencode/tools` 中的工具实现,驱动地图定位、图表、SCADA、历史数据和业务 API 调用。 - 通过 `data/` 保存运行时会话元数据、结果引用和本地状态。 ## 目录结构 @@ -21,7 +21,8 @@ src/runtime/ OpenCode 运行时管理 src/session/ 会话映射和运行上下文 src/mcp/ MCP 服务与工具桥接 .opencode/agents/ Agent prompt 和模型行为配置 -.opencode/tools/ OpenCode 自定义工具 +.opencode/plugins/ OpenCode V2 插件入口 +.opencode/tools/ 插件注册的工具实现 .opencode/skills/ 可复用分析工作流 node-tests/ Node CLI 测试 data/ 本地运行时数据,禁止提交 @@ -60,14 +61,14 @@ docker build -t tjwater-agent:local . ## 运行模式 -Embedded 模式由服务进程拉起本机 OpenCode: +Embedded 模式由服务进程拉起本机 `opencode2 serve --service`。本地需要安装与 `@opencode-ai/client`、`@opencode-ai/plugin` 对齐的 V2 beta CLI;Docker 镜像会安装仓库固定版本: ```bash OPENCODE_MODE=embedded TJWATER_API_BASE_URL=http://127.0.0.1:8000 ``` -Client 模式连接外部 OpenCode server: +Client 模式连接外部 OpenCode V2 service: ```bash OPENCODE_MODE=client diff --git a/bun.lock b/bun.lock index cad1fb8..d622230 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "tjwater-agent", "dependencies": { - "@opencode-ai/sdk": "^1.16.2", + "@opencode-ai/client": "0.0.0-next-16741", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", @@ -26,10 +26,28 @@ "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=="], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], + "@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=="], "@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=="], @@ -80,8 +98,6 @@ "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=="], @@ -90,12 +106,16 @@ "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=="], @@ -112,12 +132,16 @@ "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=="], @@ -142,12 +166,14 @@ "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=="], - "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], + "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], @@ -166,8 +192,16 @@ "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=="], @@ -182,8 +216,6 @@ "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=="], @@ -200,6 +232,8 @@ "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=="], @@ -224,10 +258,6 @@ "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=="], @@ -248,6 +278,8 @@ "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=="], @@ -258,9 +290,9 @@ "utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="], - "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="], - "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], diff --git a/entrypoint.sh b/entrypoint.sh index 770482c..7ed393f 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,6 +2,6 @@ set -e # 直接启动 TJWaterAgent -# SDK 会根据 src/runtime/opencode.ts 中的逻辑自动管理 opencode 实例 +# V2 client service helper 会根据 src/runtime/opencode.ts 自动管理 opencode2 service echo "Starting TJWaterAgent..." exec bun run start diff --git a/opencode.json b/opencode.json index 26899b3..19b07b0 100644 --- a/opencode.json +++ b/opencode.json @@ -1,32 +1,25 @@ { "$schema": "https://opencode.ai/config.json", - "provider": { + "providers": { "deepseek": { - "options": { + "settings": { "apiKey": "{env:DEEPSEEK_API_KEY}" } } }, "model": "deepseek/deepseek-v4-flash", - "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" - }, + "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" } + ], "default_agent": "instruction" } diff --git a/package.json b/package.json index 8ca0d0f..d391dfe 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "start:prod": "bun run check && bun src/server.ts" }, "dependencies": { - "@opencode-ai/sdk": "^1.16.2", + "@opencode-ai/client": "0.0.0-next-16741", "cors": "^2.8.5", "dotenv": "^17.2.3", "express": "^4.21.2", diff --git a/src/config.ts b/src/config.ts index b7d8456..8d1fd01 100644 --- a/src/config.ts +++ b/src/config.ts @@ -41,13 +41,9 @@ 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 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server。 + // OpenCode V2 运行模式:embedded 启动 opencode2 service;client 连接已有 V2 service。 OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), - // 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 V2 client 等待会话状态变化的超时时间(毫秒)。 OPENCODE_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), // 默认使用的 opencode 模型标识。 OPENCODE_MODEL: z.string().default("deepseek/deepseek-v4-flash"), @@ -55,10 +51,8 @@ const envSchema = z OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson), // opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。 OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"), - // client 模式下,目标 opencode server 的基础地址。 + // client 模式下,目标 OpenCode V2 service 的基础地址。 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 的基础地址。 @@ -158,11 +152,7 @@ const normalizedEnv = { ...process.env, OPENCODE_MODE: process.env.OPENCODE_MODE ?? - (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, + (process.env.OPENCODE_CLIENT_BASE_URL ? "client" : "embedded"), }; export const config: AppConfig = envSchema.parse(normalizedEnv); diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index 8bb75f4..7e2a2ba 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -1,15 +1,13 @@ -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, @@ -22,23 +20,15 @@ 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, @@ -150,10 +140,7 @@ 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(); @@ -171,6 +158,10 @@ 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, @@ -279,7 +270,7 @@ export const streamPromptResponse = async ({ break; } - const nextEvent = iterator + pendingNextEvent ??= iterator .next() .then((result) => ({ type: "event" as const, result })); const nextPrompt = promptSettled @@ -290,7 +281,7 @@ export const streamPromptResponse = async ({ ); const next = await Promise.race( [ - ...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]), + ...(nextPrompt ? [pendingNextEvent, nextPrompt] : [pendingNextEvent]), ...(abortPromise ? [abortPromise] : []), ], ); @@ -306,6 +297,7 @@ export const streamPromptResponse = async ({ if (next.type === "prompt") { continue; } + pendingNextEvent = null; if (next.result.done) { break; } @@ -326,11 +318,10 @@ export const streamPromptResponse = async ({ } if (event.type === "session.status") { - const nextStatus = event.properties.status.type; + const nextStatus = event.status.type; const nextStatusMessage = - "message" in event.properties.status && - typeof event.properties.status.message === "string" - ? event.properties.status.message + "message" in event.status && typeof event.status.message === "string" + ? event.status.message : null; if ( nextStatus !== lastSessionStatus || @@ -348,14 +339,14 @@ export const streamPromptResponse = async ({ emitProgress({ id: "session-status", phase: "session", - status: event.properties.status.type === "idle" ? "completed" : "running", + status: event.status.type === "idle" ? "completed" : "running", title: - event.properties.status.type === "retry" - ? `模型请求重试中:${event.properties.status.message}` - : event.properties.status.type === "busy" + event.status.type === "retry" + ? `模型请求重试中:${event.status.message}` + : event.status.type === "busy" ? "Agent 正在处理请求" : "Agent 已空闲", - detail: buildSessionStatusDetail(event.properties.status), + detail: buildSessionStatusDetail(event.status), }); continue; } @@ -374,13 +365,13 @@ export const streamPromptResponse = async ({ sawResponseActivity = true; logDevelopmentDebug("permission request received", { ...debugContext, - requestId: event.properties.id, - permission: event.properties.permission, - patterns: event.properties.patterns, + requestId: event.request.id, + permission: event.request.action, + patterns: event.request.resources, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `permission-${event.properties.id}`, + id: `permission-${event.request.id}`, phase: "permission", status: approvalMode === "always" ? "completed" : "running", title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", @@ -391,70 +382,25 @@ export const streamPromptResponse = async ({ }); if (approvalMode === "always") { await runtime.replyPermission({ - requestId: event.properties.id, + requestId: event.request.id, sessionId, reply: "always", }); write("permission_response", { session_id: clientSessionId, - request_id: event.properties.id, + request_id: event.request.id, reply: "always" satisfies PermissionReply, }); continue; } write("permission_request", { session_id: clientSessionId, - 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, + 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, created_at: Date.now(), } satisfies PermissionRequestPayload); continue; @@ -464,78 +410,47 @@ export const streamPromptResponse = async ({ sawResponseActivity = true; logDevelopmentDebug("permission request replied", { ...debugContext, - requestId: event.properties.requestID, - reply: event.properties.reply, + requestId: event.requestId, + reply: event.reply, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `permission-${event.properties.requestID}`, + id: `permission-${event.requestId}`, phase: "permission", - status: event.properties.reply === "reject" ? "error" : "completed", + status: event.reply === "reject" ? "error" : "completed", title: - event.properties.reply === "reject" + event.reply === "reject" ? "权限请求已拒绝" : "权限请求已允许", detail: - event.properties.reply === "always" + event.reply === "always" ? "已允许本次请求,并记住同类权限。" - : event.properties.reply === "once" + : event.reply === "once" ? "已允许本次请求。" : "已拒绝本次请求。", }); write("permission_response", { session_id: clientSessionId, - request_id: event.properties.requestID, - reply: event.properties.reply satisfies PermissionReply, + request_id: event.requestId, + reply: event.reply satisfies PermissionReply, }); continue; } - if (isPermissionV2RepliedEvent(event)) { - sawResponseActivity = true; - logDevelopmentDebug("permission v2 request replied", { - ...debugContext, - requestId: event.properties.requestID, - reply: event.properties.reply, - elapsedMs: Math.max(0, Date.now() - requestStartedAt), - }); - emitProgress({ - 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)) { + if (isQuestionAskedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request received", { ...debugContext, - requestId: event.properties.id, - questionCount: event.properties.questions.length, + requestId: event.request.id, + questionCount: event.request.questions.length, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `question-${event.properties.id}`, + id: `question-${event.request.id}`, phase: "question", status: "running", title: "等待用户补充信息", - detail: event.properties.questions + detail: event.request.questions .map((question) => question.question) .join("\n"), }); @@ -545,40 +460,40 @@ export const streamPromptResponse = async ({ continue; } - if (isQuestionRepliedEvent(event) || isQuestionV2RepliedEvent(event)) { + if (isQuestionRepliedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request replied", { ...debugContext, - requestId: event.properties.requestID, + requestId: event.requestId, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `question-${event.properties.requestID}`, + id: `question-${event.requestId}`, phase: "question", status: "completed", title: "已收到补充信息", - detail: normalizeQuestionAnswers(event.properties.answers) + detail: normalizeQuestionAnswers(event.answers) .map((answer) => answer.join("、")) .filter(Boolean) .join("\n"), }); write("question_response", { session_id: clientSessionId, - request_id: event.properties.requestID, - answers: normalizeQuestionAnswers(event.properties.answers), + request_id: event.requestId, + answers: normalizeQuestionAnswers(event.answers), }); continue; } - if (isQuestionRejectedEvent(event) || isQuestionV2RejectedEvent(event)) { + if (isQuestionRejectedEvent(event)) { sawResponseActivity = true; logDevelopmentDebug("question request rejected", { ...debugContext, - requestId: event.properties.requestID, + requestId: event.requestId, elapsedMs: Math.max(0, Date.now() - requestStartedAt), }); emitProgress({ - id: `question-${event.properties.requestID}`, + id: `question-${event.requestId}`, phase: "question", status: "completed", title: "已跳过补充信息", @@ -586,7 +501,7 @@ export const streamPromptResponse = async ({ }); write("question_response", { session_id: clientSessionId, - request_id: event.properties.requestID, + request_id: event.requestId, rejected: true, }); continue; @@ -617,261 +532,209 @@ export const streamPromptResponse = async ({ }); } - if (event.type === "message.updated") { - if (event.properties.info.role === "assistant") { - sawResponseActivity = true; - } - continue; - } - - if (event.type === "message.part.delta" && event.properties.field === "text") { + if (event.type === "text.delta") { sawResponseActivity = true; - 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), - }); - } - emittedText = true; - write("token", { - session_id: clientSessionId, - 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 === "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 (!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 (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 === "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: "todo-progress", - phase: "planning", - status: completed === todos.length ? "completed" : "running", - title: `计划进度 ${completed}/${todos.length}`, - detail: todos - .map((todo) => `${todo.status}: ${todo.content}`) - .join("\n"), - }); - write("todo_update", { + emittedText = true; + write("token", { session_id: clientSessionId, - todos: normalizedTodos, - created_at: Date.now(), - } satisfies TodoUpdatePayload); + content: event.delta, + }); continue; } - if (event.type === "session.error") { + if (event.type === "reasoning.updated") { 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", + ); + } + 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", { + session_id: clientSessionId, + tool: part.tool, + params: toolParams, + reason, + }); + } + continue; + } + + if (event.type === "session.execution.started") { + emitProgress({ + id: "session-status", + phase: "session", + status: "running", + title: "Agent 正在处理", + detail: "OpenCode 已开始执行当前请求。", + }); + continue; + } + + if (event.type === "session.execution.succeeded") { + emitProgress({ + id: "session-status", + phase: "session", + status: "completed", + title: "Agent 已完成处理", + detail: "当前请求已执行完成,正在整理并返回结果。", + }); + done = true; + continue; + } + + if (event.type === "session.execution.failed" || event.type === "session.error") { + sawResponseActivity = true; + const runtimeError = event.error; logDevelopmentDebug("session error received", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), - error: event.properties.error - ? getErrorMessage(event.properties.error) + error: runtimeError + ? getErrorMessage(runtimeError) : "opencode session error", }); write("error", { session_id: clientSessionId, - message: event.properties.error - ? getErrorMessage(event.properties.error) + message: runtimeError + ? getErrorMessage(runtimeError) : "opencode session error", - detail: event.properties.error?.name, + 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", total_duration_ms: Math.max(0, Date.now() - requestStartedAt), }); failed = true; @@ -880,27 +743,10 @@ export const streamPromptResponse = async ({ } if (event.type === "session.idle") { - if (!sawResponseActivity) { - logDevelopmentDebug("ignoring session idle before response activity", { - ...debugContext, - elapsedMs: Math.max(0, Date.now() - requestStartedAt), - }); - continue; - } - logDevelopmentDebug("session idle received", { + logDevelopmentDebug("ignoring legacy session.idle event", { ...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 ecb44d1..bcab727 100644 --- a/src/routes/chatStreamEvents.ts +++ b/src/routes/chatStreamEvents.ts @@ -1,7 +1,9 @@ -import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; - import { logger } from "../logger.js"; -import { type QuestionAnswers } from "../runtime/opencode.js"; +import { + type QuestionAnswers, + type RuntimeEvent, + type RuntimePart, +} from "../runtime/opencode.js"; export type PermissionRequestPayload = { session_id: string; @@ -139,24 +141,16 @@ export const extractRequestReason = (params: Record) => { return ""; }; -export const isSkillEvent = (event: OpencodeEvent) => - event.type.toLowerCase().includes("skill"); +export const isSkillEvent = (event: RuntimeEvent) => + event.type === "skill.activated"; -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; +export const extractSkillAuditInfo = ( + event: Extract, +) => { + const payload = event.payload; const reason = extractRequestReason(payload); return { - name: candidateName, + name: event.name, reason, payload, }; @@ -165,88 +159,50 @@ export const extractSkillAuditInfo = (event: OpencodeEvent) => { export const hasToolParams = (params: Record) => Object.keys(params).length > 0; -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 isSessionEvent = (event: RuntimeEvent, sessionId: string) => + event.sessionId === sessionId; export const isPermissionAskedEvent = ( - event: OpencodeEvent, -): event is Extract => + event: RuntimeEvent, +): event is Extract => event.type === "permission.asked"; -export const isPermissionV2AskedEvent = ( - event: OpencodeEvent, -): event is Extract => - event.type === "permission.v2.asked"; - export const isPermissionRepliedEvent = ( - event: OpencodeEvent, -): event is Extract => + event: RuntimeEvent, +): event is Extract => event.type === "permission.replied"; -export const isPermissionV2RepliedEvent = ( - event: OpencodeEvent, -): event is Extract => - event.type === "permission.v2.replied"; - export const isQuestionAskedEvent = ( - event: OpencodeEvent, -): event is Extract => + event: RuntimeEvent, +): event is Extract => event.type === "question.asked"; -export const isQuestionV2AskedEvent = ( - event: OpencodeEvent, -): event is Extract => - event.type === "question.v2.asked"; - export const isQuestionRepliedEvent = ( - event: OpencodeEvent, -): event is Extract => + event: RuntimeEvent, +): event is Extract => event.type === "question.replied"; -export const isQuestionV2RepliedEvent = ( - event: OpencodeEvent, -): event is Extract => - event.type === "question.v2.replied"; - export const isQuestionRejectedEvent = ( - event: OpencodeEvent, -): event is Extract => + event: RuntimeEvent, +): 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 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}`; + const resources = event.request.resources.length + ? event.request.resources.join(", ") + : event.request.action; + return `需要用户确认权限:${event.request.action};资源:${resources}`; }; export const normalizeQuestionPayload = ( - event: Extract, + event: Extract, clientSessionId: string, ): QuestionRequestPayload => ({ session_id: clientSessionId, - request_id: event.properties.id, - questions: event.properties.questions.map((question) => ({ + request_id: event.request.id, + questions: event.request.questions.map((question) => ({ header: question.header, question: question.question, options: question.options.map((option) => ({ @@ -256,7 +212,7 @@ export const normalizeQuestionPayload = ( multiple: question.multiple, custom: question.custom, })), - tool: event.properties.tool, + tool: event.request.tool, created_at: Date.now(), }); @@ -269,65 +225,6 @@ 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; @@ -344,9 +241,12 @@ export const normalizeTodoPriority = ( return undefined; }; -export const collectTextContent = (parts: Part[]) => +export const collectTextContent = (parts: RuntimePart[]) => 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 067cdb2..ee00ec0 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -1,32 +1,38 @@ -import { - createOpencode, - createOpencodeClient, - type OpencodeClient, -} from "@opencode-ai/sdk/v2"; -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; +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 { 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) { - return; + if (isDevelopmentDebugLoggingEnabled) { + logger.info(metadata, message); } - logger.info(metadata, message); }; export type RuntimeHealth = { healthy: boolean; version: string; + pid?: number; }; -type RuntimeModelOverride = { +export type RuntimeModelOverride = { providerID: string; modelID: string; }; @@ -34,21 +40,145 @@ type RuntimeModelOverride = { export type PermissionReply = "once" | "always" | "reject"; export type QuestionAnswers = string[][]; -type RuntimeMessage = { +export type RuntimePart = + | { type: "text"; text: string } + | { type: "reasoning"; text: string }; + +export type RuntimeMessage = { info: { id: string; role: string; }; + parts: RuntimePart[]; }; -const getRuntimeMessageRole = (message: RuntimeMessage) => message.info.role; -const getRuntimeMessageId = (message: RuntimeMessage) => message.info.id; +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: [], + }; +}; export class OpencodeRuntimeAdapter { - private clientPromise: Promise | null = null; - private closeServer: (() => void) | null = null; + private clientPromise: Promise | null = null; + private ownsEmbeddedService = false; + private embeddedServiceFile: string | undefined; + private readonly pendingForms = new Map(); - async ensureClient(): Promise { + async ensureClient(): Promise { if (!this.clientPromise) { this.clientPromise = this.bootstrapClient().catch((error) => { this.clientPromise = null; @@ -60,34 +190,26 @@ export class OpencodeRuntimeAdapter { async health(): Promise { const client = await this.ensureClient(); - const response = await client.global.health(); - return requireData(response.data, "global.health"); + return requireCompatibleHealth(await client.health.get()); } async warmup(): Promise { const client = await this.ensureClient(); - const healthResponse = await client.global.health(); - requireData(healthResponse.data, "global.health"); - - const sessionResponse = await client.session.create({ + requireCompatibleHealth(await client.health.get()); + const session = await client.session.create({ title: "tjwater-agent-warmup", + agent: "instruction", + model: toModelRef(getConfiguredModel()), + location: { directory: process.cwd() }, }); - const session = requireData(sessionResponse.data, "session.create"); try { - const [provider, model] = config.OPENCODE_MODEL.split("/"); - if (!provider || !model) { - throw new Error( - `invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`, - ); - } - const toolsResponse = await client.tool.list({ provider, model }); - requireData(toolsResponse.data, "tool.list"); + await Promise.all([ + client.model.list({ location: { directory: process.cwd() } }), + client.plugin.list({ location: { directory: process.cwd() } }), + ]); } finally { - await client.session.delete( - { sessionID: session.id }, - { throwOnError: true }, - ).catch((error) => { + await client.session.remove({ sessionID: session.id }).catch((error: unknown) => { logger.warn( { err: error, sessionId: session.id }, "failed to remove opencode warmup session", @@ -96,78 +218,73 @@ export class OpencodeRuntimeAdapter { } } - async createSession(title?: string) { + async createSession(title?: string): Promise<{ id: string }> { const client = await this.ensureClient(); - const response = await client.session.create({ + const session = await client.session.create({ title, + agent: "instruction", + model: toModelRef(getConfiguredModel()), + location: { directory: process.cwd() }, }); - return requireData(response.data, "session.create"); + if (!session.id) { + throw new Error("OpenCode V2 returned a session without an id"); + } + const id = session.id; + return { id }; } 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 session.prompt", - { - sessionId, - model: model ?? null, - textChars: text.length, - }, - ); + logDevelopmentDebug("dispatching opencode v2 session.prompt", { + sessionId, + model: model ?? null, + textChars: text.length, + }); + if (model) { + await client.session.switchModel({ + sessionID: sessionId, + model: toModelRef(model), + }); + } await client.session.prompt({ sessionID: sessionId, - model, - parts: [{ type: "text", text }], + text, + resume: true, + }); + logDevelopmentDebug("opencode v2 session.prompt returned", { + sessionId, + elapsedMs: Math.max(0, Date.now() - startedAt), }); - logDevelopmentDebug( - "opencode session.prompt returned", - { - sessionId, - elapsedMs: Math.max(0, Date.now() - startedAt), - }, - ); } - async messages(sessionId: string, limit = 20) { + async messages(sessionId: string, limit = 20): Promise { const client = await this.ensureClient(); - const messages = await client.session.messages({ + const response = await client.message.list({ sessionID: sessionId, limit, + order: "asc", }); - return requireData(messages.data, "session.messages"); + return response.data.map(toRuntimeMessage); } async revertMessage(sessionId: string, messageId: string) { const client = await this.ensureClient(); - const response = await client.session.revert({ + return client.session.revert.stage({ 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) => getRuntimeMessageRole(message) === "user", - ); + const userMessages = messages.filter((message) => message.info.role === "user"); const targetUserMessage = userMessages[options.userOrdinal - 1]; if (!targetUserMessage) { @@ -181,326 +298,506 @@ export class OpencodeRuntimeAdapter { throw new Error("target user message not found to revert"); } - 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", - ); - } - } + 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 }); } async abortSession(sessionId: string) { const client = await this.ensureClient(); - const response = await client.session.abort({ - sessionID: sessionId, - }); - return requireData(response.data, "session.abort"); + await client.session.interrupt({ sessionID: sessionId }); } async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) { const client = await this.ensureClient(); - 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", + await client.session.wait( + { sessionID: sessionId }, + { signal: AbortSignal.timeout(timeoutMs) }, ); } - async subscribeEvents() { + async subscribeEvents(): Promise> { const client = await this.ensureClient(); - const response = await client.event.subscribe(); - return response.stream; + return normalizeEventStream(client.event.subscribe(), this.pendingForms); } async replyPermission(options: { requestId: string; - sessionId?: string; + sessionId: string; reply: PermissionReply; message?: string; }) { const client = await this.ensureClient(); - 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"); + await client.permission.reply({ + sessionID: options.sessionId, + requestID: options.requestId, + reply: options.reply, + message: options.message, + }); } async replyQuestion(options: { requestId: string; - sessionId?: string; + sessionId: string; answers: QuestionAnswers; }) { const client = await this.ensureClient(); - 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; - } - } - } - - 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({ + 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, - requestID: options.requestId, - questionV2Reply: { - answers: options.answers, - }, + formID: options.requestId, + answer: toFormAnswer(fields, options.answers), }); - return response.data; + this.pendingForms.delete(options.requestId); + return; } - - throw new Error("opencode question reply API is unavailable"); + await client.question.reply({ + sessionID: options.sessionId, + requestID: options.requestId, + answers: options.answers, + }); } - async rejectQuestion(options: { - requestId: string; - sessionId?: string; - }) { + async rejectQuestion(options: { requestId: string; sessionId: string }) { const client = await this.ensureClient(); - 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; - } - } - } - - 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({ + if (isFormRequestId(options.requestId)) { + await client.form.cancel({ sessionID: options.sessionId, - requestID: options.requestId, + formID: options.requestId, }); - return response.data; + this.pendingForms.delete(options.requestId); + return; } - - throw new Error("opencode question reject API is unavailable"); + await client.question.reject({ + sessionID: options.sessionId, + requestID: options.requestId, + }); } async dispose(): Promise { - this.closeServer?.(); - this.closeServer = null; + 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.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 server in client mode", + { baseUrl: config.OPENCODE_CLIENT_BASE_URL, mode: config.OPENCODE_MODE }, + "connecting to opencode v2 service in client mode", ); - return createOpencodeClient({ - baseUrl: config.OPENCODE_CLIENT_BASE_URL, - }); + return createClient({ 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( - { - hostname: config.OPENCODE_HOSTNAME, - port: config.OPENCODE_PORT, - model: config.OPENCODE_MODEL, - mode: config.OPENCODE_MODE, - }, - "starting opencode server in embedded mode", + { model: config.OPENCODE_MODEL, mode: config.OPENCODE_MODE }, + "starting opencode v2 service in embedded mode", ); - const startedAt = Date.now(); - let runtime; + 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; try { - runtime = await createOpencode({ - hostname: config.OPENCODE_HOSTNAME, - port: config.OPENCODE_PORT, - timeout: config.OPENCODE_TIMEOUT_MS, - config: buildOpencodeConfig(), + // 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; + }, }); } catch (error) { + this.embeddedServiceFile = undefined; if (isMissingOpencodeCli(error)) { throw new 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", + "embedded mode requires the opencode2 CLI in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL", + { cause: error }, ); } 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 server started in embedded mode", + "opencode v2 service started in embedded mode", ); - - this.closeServer = () => { - runtime.server.close(); - }; - - return runtime.client; + return createClient({ + baseUrl: endpoint.url, + headers: Service.headers(endpoint), + }); } } export const opencodeRuntime = new OpencodeRuntimeAdapter(); -function buildOpencodeConfig(): Record { - return deepMerge( - deepMerge(readProjectOpencodeConfig(), readEnvOpencodeConfig()), - { - model: config.OPENCODE_MODEL, - }, +function isMissingOpencodeCli(error: unknown): boolean { + const cause = error instanceof Error ? error.cause : undefined; + return ( + (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") || + (typeof cause === "object" && cause !== null && "code" in cause && cause.code === "ENOENT") ); } -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; +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; } } - return next; } -function isPlainObject(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +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; + } } -function isMissingOpencodeCli(error: unknown): error is NodeJS.ErrnoException { - return ( - typeof error === "object" && - error !== null && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" +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 requireData(data: T | undefined, operation: string): T { - if (data === undefined) { - throw new Error(`${operation} returned no data`); +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); } - return data; + 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 delay(ms: number) { - return new Promise((resolve) => { - setTimeout(resolve, ms); +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 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 b59cb0f..07fb18e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -529,7 +529,7 @@ const shutdown = async () => { logger.info("shutting down TJWaterAgent"); server.close(); resultReferenceStore.stopCleanupLoop(); - // 同步关闭 embedded opencode server,避免本服务退出后留下孤儿进程。 + // 同步关闭 embedded OpenCode V2 service,避免本服务退出后留下孤儿进程。 await opencodeRuntime.dispose(); }; diff --git a/tests/opencode/skillManagerTool.test.ts b/tests/opencode/skillManagerTool.test.ts index a453f5d..1a67944 100644 --- a/tests/opencode/skillManagerTool.test.ts +++ b/tests/opencode/skillManagerTool.test.ts @@ -7,6 +7,9 @@ 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; @@ -61,7 +64,7 @@ describe("skill_manager tool", () => { ); const writeResult = JSON.parse( - await tool.execute( + await toolContent(tool.execute( { action: "write_skill", content: skillDocument("# Pressure Review"), @@ -69,7 +72,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( @@ -77,7 +80,7 @@ describe("skill_manager tool", () => { ); const updateResult = JSON.parse( - await tool.execute( + await toolContent(tool.execute( { action: "write_skill", content: skillDocument("# Updated Pressure Review"), @@ -85,7 +88,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( @@ -93,14 +96,14 @@ describe("skill_manager tool", () => { ); const removeResult = JSON.parse( - await tool.execute( + await toolContent(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(); @@ -114,7 +117,7 @@ describe("skill_manager tool", () => { ); const writeResult = JSON.parse( - await tool.execute( + await toolContent(tool.execute( { action: "write_skill", content: [ @@ -129,7 +132,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 6a39e84..73ae420 100644 --- a/tests/routes/chatStream.test.ts +++ b/tests/routes/chatStream.test.ts @@ -21,20 +21,18 @@ describe("streamPromptResponse", () => { createEventStream([ { type: "permission.asked", - properties: { + sessionId: "runtime-session-1", + request: { id: "perm-1", - sessionID: "runtime-session-1", - permission: "bash", - patterns: ["rm *"], + action: "shell", + resources: ["rm *"], metadata: { command: "rm tmp.txt" }, - always: ["rm *"], + save: ["rm *"], }, }, { - type: "session.idle", - properties: { - sessionID: "runtime-session-1", - }, + type: "session.execution.succeeded", + sessionId: "runtime-session-1", }, ]), prompt: async () => undefined, @@ -54,7 +52,7 @@ describe("streamPromptResponse", () => { expect(permissionEvent?.data).toMatchObject({ session_id: "client-session-1", request_id: "perm-1", - permission: "bash", + permission: "shell", patterns: ["rm *"], target: "rm tmp.txt", always: ["rm *"], @@ -68,20 +66,18 @@ describe("streamPromptResponse", () => { createEventStream([ { type: "permission.asked", - properties: { + sessionId: "runtime-session-1", + request: { id: "perm-1", - sessionID: "runtime-session-1", - permission: "bash", - patterns: ["npm test"], + action: "shell", + resources: ["npm test"], metadata: { command: "npm test" }, - always: ["npm test"], + save: ["npm test"], }, }, { - type: "session.idle", - properties: { - sessionID: "runtime-session-1", - }, + type: "session.execution.succeeded", + sessionId: "runtime-session-1", }, ]), prompt: async () => undefined, @@ -116,15 +112,15 @@ describe("streamPromptResponse", () => { }); }); - it("forwards opencode v2 permission requests as SSE payloads", async () => { + it("forwards external-directory V2 permission requests as SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { - type: "permission.v2.asked", - properties: { + type: "permission.asked", + sessionId: "runtime-session-1", + request: { id: "perm-v2-1", - sessionID: "runtime-session-1", action: "external_directory", resources: ["/tmp"], save: ["/tmp"], @@ -132,10 +128,8 @@ describe("streamPromptResponse", () => { }, }, { - type: "session.idle", - properties: { - sessionID: "runtime-session-1", - }, + type: "session.execution.succeeded", + sessionId: "runtime-session-1", }, ]), prompt: async () => undefined, @@ -168,9 +162,9 @@ describe("streamPromptResponse", () => { createEventStream([ { type: "question.asked", - properties: { + sessionId: "runtime-session-1", + request: { id: "question-1", - sessionID: "runtime-session-1", questions: [ { header: "范围", @@ -184,17 +178,13 @@ describe("streamPromptResponse", () => { }, { type: "question.replied", - properties: { - sessionID: "runtime-session-1", - requestID: "question-1", - answers: [["城区", "补充说明"]], - }, + sessionId: "runtime-session-1", + requestId: "question-1", + answers: [["城区", "补充说明"]], }, { - type: "session.idle", - properties: { - sessionID: "runtime-session-1", - }, + type: "session.execution.succeeded", + sessionId: "runtime-session-1", }, ]), prompt: async () => undefined, @@ -230,19 +220,16 @@ describe("streamPromptResponse", () => { }); }); - it("converts question tool parts into question request SSE payloads", async () => { + it("waits for the Form event instead of exposing the question tool call ID", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { - type: "message.part.updated", - properties: { - sessionID: "runtime-session-1", - part: { + type: "tool.updated", + sessionId: "runtime-session-1", + part: { id: "tool-part-1", - sessionID: "runtime-session-1", messageID: "message-1", - type: "tool", callID: "call-1", tool: "question", state: { @@ -261,17 +248,31 @@ describe("streamPromptResponse", () => { }, ], }, - time: { start: Date.now() }, }, }, - time: Date.now(), + }, + { + type: "question.asked", + sessionId: "runtime-session-1", + request: { + id: "frm_1", + questions: [ + { + header: "测试问题", + question: "你觉得这个 question 工具好用吗?", + options: [ + { + label: "非常好用", + description: "交互清晰,选项方便", + }, + ], + }, + ], }, }, { - type: "session.idle", - properties: { - sessionID: "runtime-session-1", - }, + type: "session.execution.succeeded", + sessionId: "runtime-session-1", }, ]), prompt: async () => undefined, @@ -289,7 +290,7 @@ describe("streamPromptResponse", () => { expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({ session_id: "client-session-1", - request_id: "call-1", + request_id: "frm_1", questions: [ { header: "测试问题", @@ -302,11 +303,12 @@ 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", @@ -314,25 +316,32 @@ describe("streamPromptResponse", () => { ).toBe(false); }); - it("forwards todo updates as structured SSE payloads and progress", async () => { + it("forwards normalized V2 text and reasoning deltas", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { - 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", + delta: "分析中", + completed: false, }, { - type: "session.idle", - properties: { - sessionID: "runtime-session-1", - }, + 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", }, ]), prompt: async () => undefined, @@ -348,30 +357,83 @@ 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 === "todo-progress", + (item) => + item.event === "progress" && + item.data.id === "reasoning-1" && + item.data.status === "completed", )?.data, ).toMatchObject({ - id: "todo-progress", phase: "planning", - 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", - }), - ], + title: "分析规划完成", }); }); + 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 7fb4548..042df8e 100644 --- a/tests/runtime/opencode.test.ts +++ b/tests/runtime/opencode.test.ts @@ -1,34 +1,57 @@ import { describe, expect, it } from "bun:test"; -import { type OpencodeClient } from "@opencode-ai/sdk/v2"; +import { type OpenCodeClient } from "@opencode-ai/client"; -import { config } from "../../src/config.js"; -import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; +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", + ); + }); +}); const createRuntimeAdapter = ( messages: unknown[], - calls: { - reverted: string[]; - removed: string[]; - } = { reverted: [], removed: [] }, + calls: { staged: string[]; committed: string[] } = { + staged: [], + committed: [], + }, ) => Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), { messages: async () => messages, - revertMessage: async (_sessionId: string, messageId: string) => { - calls.reverted.push(messageId); - }, - removeMessage: async (_sessionId: string, messageId: string) => { - calls.removed.push(messageId); - }, + 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, }) as OpencodeRuntimeAdapter; describe("OpencodeRuntimeAdapter.revertToUserMessage", () => { it("skips reverting the first user message when the runtime session is empty", async () => { - const calls = { reverted: [] as string[], removed: [] as string[] }; + const calls = { staged: [] as string[], committed: [] as string[] }; const runtime = createRuntimeAdapter([], calls); await runtime.revertToUserMessage("session-1", { userOrdinal: 1 }); - expect(calls).toEqual({ reverted: [], removed: [] }); + expect(calls).toEqual({ staged: [], committed: [] }); }); it("keeps ordinal mismatches visible when runtime messages exist", async () => { @@ -42,8 +65,8 @@ describe("OpencodeRuntimeAdapter.revertToUserMessage", () => { ).rejects.toThrow("target user message not found to revert"); }); - it("reverts and removes messages from the target user message onward", async () => { - const calls = { reverted: [] as string[], removed: [] as string[] }; + it("stages and commits the V2 revert at the target user message", async () => { + const calls = { staged: [] as string[], committed: [] as string[] }; const runtime = createRuntimeAdapter( [ { info: { id: "user-1", role: "user" } }, @@ -57,8 +80,8 @@ describe("OpencodeRuntimeAdapter.revertToUserMessage", () => { await runtime.revertToUserMessage("session-1", { userOrdinal: 2 }); expect(calls).toEqual({ - reverted: ["user-2"], - removed: ["assistant-2", "user-2"], + staged: ["user-2"], + committed: ["session-1"], }); }); }); @@ -67,11 +90,10 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => { it("retries bootstrap after a failed startup attempt", async () => { let attempts = 0; const client = { - global: { health: async () => ({ data: { healthy: true } }) }, - } as unknown as OpencodeClient; + health: { get: async () => ({ 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) { @@ -88,45 +110,66 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => { }); describe("OpencodeRuntimeAdapter.warmup", () => { - it("initializes the project session and model tools before reporting ready", async () => { + 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 () => { const calls: string[] = []; const client = { - global: { - health: async () => { - calls.push("health"); - return { data: { healthy: true, version: "test" } }; + health: { + get: async () => { + calls.push("health.get"); + return { healthy: true, version: "0.0.0-next-16741" }; }, }, session: { create: async () => { calls.push("session.create"); - return { data: { id: "warmup-session" } }; + return { id: "warmup-session" }; }, - delete: async ({ sessionID }: { sessionID: string }) => { - calls.push(`session.delete:${sessionID}`); - return { data: true }; + remove: async ({ sessionID }: { sessionID: string }) => { + calls.push(`session.remove:${sessionID}`); }, }, - tool: { - list: async (model: { provider: string; model: string }) => { - calls.push(`tool.list:${model.provider}/${model.model}`); + model: { + list: async () => { + calls.push("model.list"); return { data: [] }; }, }, - } as unknown as OpencodeClient; + plugin: { + list: async () => { + calls.push("plugin.list"); + return []; + }, + }, + } 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", + "health.get", "session.create", - `tool.list:${config.OPENCODE_MODEL}`, - "session.delete:warmup-session", + "model.list", + "plugin.list", + "session.remove:warmup-session", ]); }); }); diff --git a/tests/runtime/opencodeInteraction.test.ts b/tests/runtime/opencodeInteraction.test.ts new file mode 100644 index 0000000..9b8960e --- /dev/null +++ b/tests/runtime/opencodeInteraction.test.ts @@ -0,0 +1,193 @@ +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" }, + ]); + }); +});