diff --git a/package.json b/package.json index 0b40a33b..928e3aad 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dist", "licenses", "scripts/prepare.mjs", + "scripts/run-step.mjs", "README.md", "README_CN.md" ], @@ -37,6 +38,8 @@ "test": "vitest run", "test:watch": "vitest", "start": "node dist/main.js", + "step": "node scripts/run-step.mjs", + "step:fresh": "node scripts/run-step.mjs --full", "prepare": "node scripts/prepare.mjs", "prepublishOnly": "npm run build", "prepack": "npm run build" @@ -72,6 +75,7 @@ "@types/adm-zip": "^0.5.8", "@types/node": "^26.1.1", "esbuild": "^0.28.1", + "expect": "^30.5.1", "postject": "1.0.0-alpha.6", "tsx": "^4.23.1", "typescript": "^7.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cdfa2549..4c71d63c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: esbuild: specifier: ^0.28.1 version: 0.28.2 + expect: + specifier: ^30.5.1 + version: 30.5.1 postject: specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6 @@ -95,6 +98,14 @@ packages: zod: optional: true + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.7': resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} engines: {node: '>=6.9.0'} @@ -268,6 +279,30 @@ packages: peerDependencies: hono: ^4 + '@jest/diff-sequences@30.5.0': + resolution: {integrity: sha512-OsqBjHXCn8cadasoAZBP6nWYvMsRhpMzGXTpxJ5aO04NlbdhIz+FVe3q49l0AwVhsz/cEmIpBes6gAFl1/dWQg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/expect-utils@30.5.1': + resolution: {integrity: sha512-WcRWhHQdTMRDpyWKZ/6MINBmovI7zeD+bL8wFjCncRV3NQOwKy1X45IfyblfHR4k/XciIlNEdFL9QjFO+HNKOg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/get-type@30.5.0': + resolution: {integrity: sha512-9/2VUPitAjmBzbvDvqrxmvB7BzWsBW0WmkkojX1ODuxX1NLGxx9gfaZpHB0z8DtJ9uhGNmZG/VXBhf8uO0OV8Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/pattern@30.5.0': + resolution: {integrity: sha512-HdNQYSdRTEBNrginaqzQtTjG0HRMfrra/z6Ok7uL3S87vSlarIVohEsJsSj5edu3MiHoHjAkvPROz5ZjoKai+w==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/schemas@30.5.0': + resolution: {integrity: sha512-/hunigyNpc4RCjC0VaW3f5RCUZVM2+WQ65qP7z083Gmvac7or2LI50XVNOtE4YPgBpV0yxYiAgorAPGniCoJmg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + '@jest/types@30.5.1': + resolution: {integrity: sha512-LvVYn83nnXPl+Rg98nvcFgjx6nRMTArhSn6RAX/w3ELn54S8A42TYZvsCMdGUqTM8S0wyXbtlQdU6Hi6dykj9g==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + '@jimp/core@1.6.1': resolution: {integrity: sha512-+BoKC5G6hkrSy501zcJ2EpfnllP+avPevcBfRcZe/CW+EwEfY6X1EZ8QWyT7NpDIvEEJb1fdJnMMfUnFkxmw9A==} engines: {node: '>=18'} @@ -514,6 +549,9 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@sinclair/typebox@0.34.52': + resolution: {integrity: sha512-XiMQh7qqVlxZzcVD+kkGMNGMzcTrDMLWI7S4x7z1MkCkbDPrekpZXEUK0eZqZFMuHQg2a2DZOcDIh9o5v3Gonw==} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -539,12 +577,30 @@ packages: '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/istanbul-lib-coverage@2.0.6': + resolution: {integrity: sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==} + + '@types/istanbul-lib-report@3.0.3': + resolution: {integrity: sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==} + + '@types/istanbul-reports@3.0.4': + resolution: {integrity: sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==} + '@types/node@16.9.1': resolution: {integrity: sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g==} '@types/node@26.2.0': resolution: {integrity: sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==} + '@types/stack-utils@2.0.3': + resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + + '@types/yargs-parser@21.0.3': + resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} + + '@types/yargs@17.0.35': + resolution: {integrity: sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -725,6 +781,10 @@ packages: resolution: {integrity: sha512-Qts4KCLKG+waHc9C4m07weIY8qyeixoS0h6RnbsNVD6Fw+pEZGW3vTyObL3WXpE09Mq4Oi7/lBEyLmOiLtlYWQ==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + any-base@1.1.0: resolution: {integrity: sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==} @@ -770,10 +830,18 @@ packages: resolution: {integrity: sha512-N9oWFcegS0sFr9oh1oz2d7Npos6vNoWW9HvtCg5N1KRFpUhaAhvTv5Y58g880fZaEYSNm3qDz8SU1UrGvp+n7A==} engines: {node: '>=10'} + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + cli-highlight@2.1.11: resolution: {integrity: sha512-9KDcoEVwyUXrjcJNvHD0NFc/hiwe/WPVYIleQh2O1N2Zro5gWJZ/K+3DGn8w8P/F6FxOgzyC5bxDyHIgCSPhGg==} engines: {node: '>=8.0.0', npm: '>=5.0.0'} @@ -938,6 +1006,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: + resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} + engines: {node: '>=8'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -960,6 +1032,10 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + expect@30.5.1: + resolution: {integrity: sha512-m8YrYgvKe9+9gEnWEuKz+qCGfHqkrff7PPfyDnOFkjsfYRKqiYyOxDFMFieCGAuhtk/VNm63tvNgKZVzVy+Hvg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express-rate-limit@8.6.2: resolution: {integrity: sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A==} engines: {node: '>= 16'} @@ -1035,6 +1111,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1103,6 +1182,30 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jest-diff@30.5.1: + resolution: {integrity: sha512-e3cNNMpv8Kh20MjjphTXs+3Vz7DQyLM1nft7KJhnh46atFhjVJRa+0Hq0beywuwsACtMQUBihQkFl8zxb7gt1Q==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-matcher-utils@30.5.1: + resolution: {integrity: sha512-aroZVqwOz/wC2y6pC+obgFWKV9viaQWQTTSB6W5H55+egtUczIfXR/rxExTv92xD/ADYwpqaYfVWx1aqwKg7FA==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-message-util@30.5.1: + resolution: {integrity: sha512-UdQlLdd9wL/Ys7xRErckqwD6wPlSZYueosSWuHc1r2ztGLwlgPvtSJq2+BPEgaEY13WLvfFbmhTj8pba0Sd1jg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-mock@30.5.1: + resolution: {integrity: sha512-9fVjc3leUpGID2/by/LU4Dvdcp7PFh9LlxS3QRWK3ABm+KtvEVsG/AEGeLY3gKOZsjkBxyfwGltoAVlW7dygHg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-regex-util@30.5.0: + resolution: {integrity: sha512-Mg0WK7A6xRHLSA1udJ8y9f3lM0uUhFTBnLKzwPmqB9AylvpleJ6BLemR8K9dK27DY+cesDryoA7yLZCAHsPG1A==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + + jest-util@30.5.1: + resolution: {integrity: sha512-yKuxmNy2rSbTXw+3SIPanJo+nV4/BS1p26v44IYBFMsswSQySfMMcPHErnOncda7i9HEz0q605rIhSTBVgrZTg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + jimp@1.6.1: resolution: {integrity: sha512-hNQh6rZtWfSVWSNVmvq87N5BPJsNH7k7I7qyrXf9DOma9xATQk3fsyHazCQe51nCjdkoWdTmh0vD7bjVSLoxxw==} engines: {node: '>=18'} @@ -1113,6 +1216,9 @@ packages: jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + json-schema-to-ts@3.1.1: resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} engines: {node: '>=16'} @@ -1347,6 +1453,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + pretty-format@30.5.1: + resolution: {integrity: sha512-byhRAPguVKMQIj4kjJwJ5lAskVhfuiSdiYl/aLTWpgkGEmic2jYhJh1yE9ih8Ox44Xg9ccCT11/S6QrzSJuNrg==} + engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -1374,6 +1484,12 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + + react-is@19.2.8: + resolution: {integrity: sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -1440,6 +1556,10 @@ packages: resolution: {integrity: sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q==} engines: {node: '>=20.12.2'} + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + smol-toml@1.8.0: resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} engines: {node: '>= 18'} @@ -1448,6 +1568,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + stack-utils@2.0.6: + resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} + engines: {node: '>=10'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1705,6 +1829,14 @@ snapshots: optionalDependencies: zod: 4.4.3 + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + '@babel/runtime@7.29.7': {} '@borewit/text-codec@0.2.2': {} @@ -1796,6 +1928,33 @@ snapshots: dependencies: hono: 4.13.3 + '@jest/diff-sequences@30.5.0': {} + + '@jest/expect-utils@30.5.1': + dependencies: + '@jest/get-type': 30.5.0 + + '@jest/get-type@30.5.0': {} + + '@jest/pattern@30.5.0': + dependencies: + '@types/node': 26.2.0 + jest-regex-util: 30.5.0 + + '@jest/schemas@30.5.0': + dependencies: + '@sinclair/typebox': 0.34.52 + + '@jest/types@30.5.1': + dependencies: + '@jest/pattern': 30.5.0 + '@jest/schemas': 30.5.0 + '@types/istanbul-lib-coverage': 2.0.6 + '@types/istanbul-reports': 3.0.4 + '@types/node': 26.2.0 + '@types/yargs': 17.0.35 + chalk: 4.1.2 + '@jimp/core@1.6.1': dependencies: '@jimp/file-ops': 1.6.1 @@ -2112,6 +2271,8 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@sinclair/typebox@0.34.52': {} + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -2138,12 +2299,30 @@ snapshots: '@types/estree@1.0.9': {} + '@types/istanbul-lib-coverage@2.0.6': {} + + '@types/istanbul-lib-report@3.0.3': + dependencies: + '@types/istanbul-lib-coverage': 2.0.6 + + '@types/istanbul-reports@3.0.4': + dependencies: + '@types/istanbul-lib-report': 3.0.3 + '@types/node@16.9.1': {} '@types/node@26.2.0': dependencies: undici-types: 8.3.0 + '@types/stack-utils@2.0.3': {} + + '@types/yargs-parser@21.0.3': {} + + '@types/yargs@17.0.35': + dependencies: + '@types/yargs-parser': 21.0.3 + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -2273,6 +2452,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + any-base@1.1.0: {} any-promise@1.3.0: {} @@ -2318,8 +2499,15 @@ snapshots: ansi-styles: 4.1.0 supports-color: 7.1.0 + chalk@4.1.2: + dependencies: + ansi-styles: 4.1.0 + supports-color: 7.1.0 + chalk@5.6.2: {} + ci-info@4.4.0: {} + cli-highlight@2.1.11: dependencies: chalk: 4.0.0 @@ -2485,6 +2673,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -2501,6 +2691,15 @@ snapshots: expect-type@1.4.0: {} + expect@30.5.1: + dependencies: + '@jest/expect-utils': 30.5.1 + '@jest/get-type': 30.5.0 + jest-matcher-utils: 30.5.1 + jest-message-util: 30.5.1 + jest-mock: 30.5.1 + jest-util: 30.5.1 + express-rate-limit@8.6.2(express@5.2.1): dependencies: debug: 4.4.3 @@ -2610,6 +2809,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -2673,6 +2874,51 @@ snapshots: isexe@2.0.0: {} + jest-diff@30.5.1: + dependencies: + '@jest/diff-sequences': 30.5.0 + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + pretty-format: 30.5.1 + + jest-matcher-utils@30.5.1: + dependencies: + '@jest/get-type': 30.5.0 + chalk: 4.1.2 + jest-diff: 30.5.1 + pretty-format: 30.5.1 + + jest-message-util@30.5.1: + dependencies: + '@babel/code-frame': 7.29.7 + '@jest/types': 30.5.1 + '@types/stack-utils': 2.0.3 + chalk: 4.1.2 + graceful-fs: 4.2.11 + jest-util: 30.5.1 + picomatch: 4.0.5 + pretty-format: 30.5.1 + slash: 3.0.0 + stack-utils: 2.0.6 + + jest-mock@30.5.1: + dependencies: + '@jest/expect-utils': 30.5.1 + '@jest/types': 30.5.1 + '@types/node': 26.2.0 + jest-util: 30.5.1 + + jest-regex-util@30.5.0: {} + + jest-util@30.5.1: + dependencies: + '@jest/types': 30.5.1 + '@types/node': 26.2.0 + chalk: 4.1.2 + ci-info: 4.4.0 + graceful-fs: 4.2.11 + picomatch: 4.0.5 + jimp@1.6.1: dependencies: '@jimp/core': 1.6.1 @@ -2709,6 +2955,8 @@ snapshots: jpeg-js@0.4.4: {} + js-tokens@4.0.0: {} + json-schema-to-ts@3.1.1: dependencies: '@babel/runtime': 7.29.7 @@ -2878,6 +3126,13 @@ snapshots: dependencies: commander: 9.4.0 + pretty-format@30.5.1: + dependencies: + '@jest/react-is-18': react-is@18.3.1 + '@jest/react-is-19': react-is@19.2.8 + '@jest/schemas': 30.5.0 + ansi-styles: 5.2.0 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -2916,6 +3171,10 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + react-is@18.3.1: {} + + react-is@19.2.8: {} + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -3020,10 +3279,16 @@ snapshots: simple-xml-to-json@1.2.7: {} + slash@3.0.0: {} + smol-toml@1.8.0: {} source-map-js@1.2.1: {} + stack-utils@2.0.6: + dependencies: + escape-string-regexp: 2.0.0 + stackback@0.0.2: {} standardwebhooks@1.0.0: diff --git a/scripts/run-step.mjs b/scripts/run-step.mjs new file mode 100644 index 00000000..002400fc --- /dev/null +++ b/scripts/run-step.mjs @@ -0,0 +1,105 @@ +import path from "node:path"; +import { spawn } from "node:child_process"; + +const repoRoot = process.cwd(); +const scriptArgs = process.argv.slice(2); + +async function resolveBunBin() { + const explicit = process.env.STEP_BUN_BIN; + if (explicit) { + return explicit; + } + + const candidates = [ + "bun", + path.join(process.env.USERPROFILE ?? "", ".bun", "bin", "bun.exe"), + path.join(process.env.LOCALAPPDATA ?? "", "Programs", "bun", "bun.exe"), + ]; + + for (const candidate of candidates) { + const ok = await checkCommand(candidate); + if (ok) { + return candidate; + } + } + + return process.execPath; +} + +async function checkCommand(command) { + return new Promise((resolve) => { + let child; + try { + child = spawn(command, ["--version"], { + cwd: repoRoot, + env: process.env, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + resolve(false); + return; + } + + let stdout = ""; + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + + child.once("error", () => { + resolve(false); + }); + + child.once("close", (code) => { + resolve(code === 0 && stdout.trim().length > 0); + }); + }); +} + +async function main() { + const entrypoint = path.join(repoRoot, "src", "main.ts"); + const bunBin = await resolveBunBin(); + const isNode = bunBin === process.execPath || /(^|[/\\])node(\.exe)?$/.test(bunBin); + + if (!isNode) { + return runCommand(bunBin, [entrypoint, ...scriptArgs]); + } + + return runCommand(process.execPath, [ + "--import", + pathToFileURL(require.resolve("tsx")).href, + entrypoint, + ...scriptArgs, + ]); +} + +function runCommand(command, commandArgs) { + return new Promise((resolve, reject) => { + const child = spawn(command, commandArgs, { + cwd: repoRoot, + env: process.env, + stdio: "inherit", + }); + + child.once("error", reject); + child.once("exit", (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + + resolve(code ?? 1); + }); + }); +} + +function pathToFileURL(path) { + return `file://${path.replace(/\\/g, "/")}`; +} + +main().then( + (exitCode) => process.exit(exitCode), + (error) => { + console.error(`step-cli wrapper error: ${error.message}`); + process.exit(1); + } +); diff --git a/src/agent/events.ts b/src/agent/events.ts index 4402bd0d..2704b3de 100644 --- a/src/agent/events.ts +++ b/src/agent/events.ts @@ -20,7 +20,7 @@ export type AgentEvent = | { type: 'tool_forming'; id: string; name: string } /** 工具参数的流式增量(半截 JSON 片段)。UI 只抠关键字段做预览,不解析全量。 */ | { type: 'tool_args_delta'; id: string; partialJson: string } - | { type: 'tool_end'; id: string; name: string; result: string; isError: boolean } + | { type: 'tool_end'; id: string; name: string; result: string; isError: boolean; errorCode?: string } /** * 重试。hadPartial 为 true 表示本次失败尝试已吐过正文(屏幕上有残文条目), * UI 据此在重试前移除该残文(B 方案:撤回气泡,只留重发的完整版); diff --git a/src/agent/hooks.ts b/src/agent/hooks.ts index 846ba0af..e58b51d3 100644 --- a/src/agent/hooks.ts +++ b/src/agent/hooks.ts @@ -8,7 +8,7 @@ export interface ToolCallRequest { } /** 授权结果:放行,或拒绝并附原因(原因会作为 tool_result 回灌给模型)。 */ -export type Authorization = { decision: 'allow' } | { decision: 'deny'; reason: string }; +export type Authorization = { decision: 'allow' } | { decision: 'deny'; reason: string; errorCode?: string }; /** 停止后续接描述:inject 为下一轮要注入的用户消息文本。 */ export interface StopContinuation { diff --git a/src/agent/runTurn.ts b/src/agent/runTurn.ts index fa377f2a..2c972510 100644 --- a/src/agent/runTurn.ts +++ b/src/agent/runTurn.ts @@ -572,7 +572,9 @@ export async function* runTurn( // 授权(Phase 3 权限系统挂在这里) const auth = await resolveAuthorization(hooks, req); if (auth.decision === 'deny') { - prepared.push({ tu, access: { kind: 'all' }, preset: { content: `工具调用被拒绝:${auth.reason}`, isError: true } }); + const preset: ToolResult = { content: `工具调用被拒绝:${auth.reason}`, isError: true }; + if (auth.errorCode !== undefined) preset.errorCode = auth.errorCode; + prepared.push({ tu, access: { kind: 'all' }, preset }); continue; } prepared.push({ @@ -644,7 +646,7 @@ export async function* runTurn( continue; } const result = p.result!; - yield { type: 'tool_end', id: p.tu.id, name: p.tu.name, result: result.content, isError: result.isError }; + yield { type: 'tool_end', id: p.tu.id, name: p.tu.name, result: result.content, isError: result.isError, errorCode: result.errorCode }; toolResults.push(makeToolResult(p.tu.id, result)); // 完成放行:被本任务卡住的后续任务现在启动,其 tool_start 排在本 tool_end 之后 scheduler.drain(); diff --git a/src/chat/streamBuffer.ts b/src/chat/streamBuffer.ts index 696f22fe..f4ecd7fe 100644 --- a/src/chat/streamBuffer.ts +++ b/src/chat/streamBuffer.ts @@ -42,7 +42,7 @@ function isStreamDelta(ev: AgentEvent): ev is Extract number; diff --git a/src/chat/types.ts b/src/chat/types.ts index 808259ad..cd8bc96c 100644 --- a/src/chat/types.ts +++ b/src/chat/types.ts @@ -46,6 +46,8 @@ export type DisplayItem = /** 子 agent 终态统计:工具调用次数与墙钟耗时(end 事件带回)。 */ subagentToolUses?: number; subagentDurationMs?: number; + /** 结构化错误码(可选)。TUI 可按 code 做特殊渲染,如 PLAN_MODE_BLOCKED。 */ + errorCode?: string; } | { kind: 'note'; diff --git a/src/tools/types.ts b/src/tools/types.ts index 5680a6b2..3a7049de 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -101,6 +101,8 @@ export interface ToolResult { * anthropic → 同形状 video 扩展块),能力不支持时由投影层换占位文本。 */ videos?: ToolResultVideo[]; + /** 结构化错误码(可选)。TUI / 协议层可按 code 区分错误类型,不再只靠 content 文本推断。 */ + errorCode?: string; } /** diff --git a/src/tui-pi/PiChat.ts b/src/tui-pi/PiChat.ts index 8cb9c83a..20d4c136 100644 --- a/src/tui-pi/PiChat.ts +++ b/src/tui-pi/PiChat.ts @@ -3038,7 +3038,7 @@ ${task.output === '' ? '(暂无输出)' : task.output}`, // plan 模式守卫:写与执行一律拒(exit_plan_mode 例外,走下方确认) if (this.planMode) { const deny = planModeDenyReason(req.name); - if (deny !== null) return { decision: 'deny', reason: deny }; + if (deny !== null) return { decision: 'deny', reason: deny, errorCode: 'PLAN_MODE_BLOCKED' }; } // exit_plan_mode:展示计划请用户确认,批准后退出 plan 并恢复原权限模式 if (req.name === 'exit_plan_mode') { @@ -3459,7 +3459,7 @@ ${task.output === '' ? '(暂无输出)' : task.output}`, this.activity.noteToolActivity(); this.transcript.updateLastWhere( (it) => it.kind === 'tool' && it.id === ev.id, - (it) => ({ ...(it as Extract), status: ev.isError ? 'error' : 'ok', result: ev.result }), + (it) => ({ ...(it as Extract), status: ev.isError ? 'error' : 'ok', result: ev.result, errorCode: ev.errorCode }), ); // todo_list 工具改的是 this.todos,面板要跟着刷;其它工具走这一路开销是两次赋值 this.chrome.setTodos(this.todos.items); diff --git a/src/tui-pi/blocks.ts b/src/tui-pi/blocks.ts index eb08f144..c2b2487e 100644 --- a/src/tui-pi/blocks.ts +++ b/src/tui-pi/blocks.ts @@ -68,6 +68,17 @@ const DIFF_MAX_LINES = 200; */ const CTRL_B_TOOLS = new Set(['bash', 'spawn_agent', 'dynamic_workflow']); +/** + * 按工具错误码返回视觉样式。不同错误码用不同颜色/标记,帮助用户一眼区分错误性质。 + * 目前只区分 PLAN_MODE_BLOCKED(计划模式拦截)和普通执行错误。 + */ +function toolErrorStyle(errorCode?: string): { mark: string; color: (s: string) => string; badge?: string } { + if (errorCode === 'PLAN_MODE_BLOCKED') { + return { mark: c.warn('✗'), color: c.warn, badge: c.accent('[plan mode]') }; + } + return { mark: c.error('✗'), color: c.error }; +} + /** 工具入参的单行摘要(折叠态标题行与 Ctrl+O 条目标题共用)。字段顺序即优先级。 */ export function summarizeInput(input: unknown): string { if (input === null || typeof input !== 'object') return ''; @@ -307,7 +318,8 @@ export class ItemBlock implements Component { } private renderTool(it: Extract, width: number): string[] { - const mark = it.status === 'running' ? c.warn(spinnerFrame()) : it.status === 'ok' ? c.ok('✓') : c.error('✗'); + const errStyle = toolErrorStyle(it.errorCode); + const mark = it.status === 'running' ? c.warn(spinnerFrame()) : it.status === 'ok' ? c.ok('✓') : errStyle.mark; const elapsed = it.status === 'running' && it.startedAt !== undefined ? c.dim(t('toolCall.elapsed', { s: Math.max(0, Math.round((Date.now() - it.startedAt) / 1000)) })) @@ -319,7 +331,8 @@ export class ItemBlock implements Component { it.subagentType !== undefined || it.description !== undefined ? c.dim(` ${[it.subagentType, it.description].filter((x) => x !== undefined).join(' · ')}`) : ''; - const head = `${mark} ${c.toolName(it.name)}${toolArgText(it)}${subagent}${elapsed}${bgHint}`; + const badge = errStyle.badge ? `${errStyle.badge} ` : ''; + const head = `${mark} ${c.toolName(it.name)}${badge}${toolArgText(it)}${subagent}${elapsed}${bgHint}`; const out = visibleWidth(head) > width ? wrap(head, width) : [head]; // dynamic_workflow 阶段:运行中逐个列出(● 当前 / ✓ 已完成),终态坍缩成一行计数 @@ -355,8 +368,9 @@ export class ItemBlock implements Component { const lines = it.result.split('\n'); if (it.status === 'error') { // 错误:预览前若干行,其余折叠 + const color = errStyle.color; for (const l of lines.slice(0, ERROR_PREVIEW_LINES)) { - out.push(...indent(wrap(c.error(l), width - 4), ' ')); + out.push(...indent(wrap(color(l), width - 4), ' ')); } if (lines.length > ERROR_PREVIEW_LINES) { out.push(c.dim(` ↳ 还有 ${lines.length - ERROR_PREVIEW_LINES} 行(Ctrl+O 查看)`)); @@ -427,17 +441,20 @@ export function extractArgsPreview(partialJson: string): string { * 头部状态行/子工具列表沿用 renderTool 的口径,这里只重做结果体。 */ function renderToolExpanded(it: Extract, width: number): string[] { - const mark = it.status === 'running' ? c.warn(spinnerFrame()) : it.status === 'ok' ? c.ok('✓') : c.error('✗'); + const errStyle = toolErrorStyle(it.errorCode); + const mark = it.status === 'running' ? c.warn(spinnerFrame()) : it.status === 'ok' ? c.ok('✓') : errStyle.mark; const subagent = it.subagentType !== undefined || it.description !== undefined ? c.dim(` ${[it.subagentType, it.description].filter((x) => x !== undefined).join(' · ')}`) : ''; - const head = `${mark} ${c.toolName(it.name)}${toolArgText(it)}${subagent}`; + const badge = errStyle.badge ? `${errStyle.badge} ` : ''; + const head = `${mark} ${c.toolName(it.name)}${badge}${toolArgText(it)}${subagent}`; const out = visibleWidth(head) > width ? wrap(head, width) : [head]; if (it.result !== undefined && it.result !== '') { const lines = it.result.split('\n'); if (it.status === 'error') { - for (const l of lines) out.push(...indent(wrap(c.error(l), width - 4), ' ')); + const color = errStyle.color; + for (const l of lines) out.push(...indent(wrap(color(l), width - 4), ' ')); } else if (looksLikeDiff(lines)) { for (const l of lines) { out.push(...indent(wrap(colorDiffLine(l), width - 4), ' ')); diff --git a/src/tui-pi/pickers.ts b/src/tui-pi/pickers.ts index 22527050..d0985465 100644 --- a/src/tui-pi/pickers.ts +++ b/src/tui-pi/pickers.ts @@ -202,6 +202,13 @@ export class PickerOverlay implements Component { this.applyFilter(); return; } + // Windows 终端 Enter 兼容:部分配置下 Enter 送来 \r/\n/\r\n 或 SS3-OM, + // pi-tui 的 matchesKey 可能漏认或误判为方向键。这里在最外层显式确认。 + if (data.includes('\r') || data.includes('\n') || data === '\x1bOM') { + const sel = this.list.getSelectedItem(); + if (sel !== null) this.onSelectItem(sel); + return; + } // ↑↓ 钳制:SelectList 内部回绕(到顶跳到底),这里在外层拦截钳制 if (matchesKey(data, 'up') || matchesKey(data, 'down')) { if (this.filteredCount > 0) { @@ -215,7 +222,11 @@ export class PickerOverlay implements Component { } return; } - this.list.handleInput(data); + // 兼容 Windows 终端 Enter 键:pi-tui 的 matchesKey 在 Kitty protocol 激活时只认 \r, + // 但部分 Windows 终端配置下 Enter 送来 \n。归一化后避免按 Enter 没反应。 + // Shift+Enter 已在上方被 onShiftSelect 拦截,这里只影响普通 Enter。 + const inputForList = data === '\n' ? '\r' : data; + this.list.handleInput(inputForList); this.requestRender(); } diff --git a/src/tui-pi/prompts.ts b/src/tui-pi/prompts.ts index d6c10ddf..8f2d5cee 100644 --- a/src/tui-pi/prompts.ts +++ b/src/tui-pi/prompts.ts @@ -188,6 +188,7 @@ type PlanValue = 'approve' | 'reject-feedback' | 'reject'; export class PlanApproval extends ChoiceBlock { private readonly done: (outcome: PlanOutcome) => void; private readonly markdown: Markdown; + private expanded = false; constructor(plan: string, requestRender: () => void, done: (outcome: PlanOutcome) => void) { super( @@ -210,12 +211,31 @@ export class PlanApproval extends ChoiceBlock { this.done({ approved: false }); } + protected override onOtherKey(data: string): void { + if (matchesKey(data, 'ctrl+e')) { + this.expanded = !this.expanded; + this.render(0); + } + } + protected override hintLine(): string { - return t('plan.hint'); + const base = t('plan.hint'); + const lines = this.markdown.render(10000); + if (lines.length <= PREVIEW_LIMIT) return base; + const action = t(this.expanded ? 'approval.hint.collapse' : 'approval.hint.expand'); + return base + t('approval.hint.previewToggle', { action }); } protected renderBody(width: number): string[] { - return [c.accent(t('plan.confirmTitle')), ...this.markdown.render(Math.max(1, width - 2)).map((l) => ` ${l}`)]; + const lines = this.markdown.render(Math.max(1, width - 2)); + const shown = this.expanded ? lines : lines.slice(0, PREVIEW_LIMIT); + const out = [c.accent(t('plan.confirmTitle')), ...shown.map((l) => ` ${l}`)]; + if (!this.expanded && lines.length > PREVIEW_LIMIT) { + out.push(c.dim(t('approval.preview.more', { rest: lines.length - PREVIEW_LIMIT }))); + } else if (this.expanded && lines.length > PREVIEW_LIMIT) { + out.push(c.dim(t('approval.preview.collapse', { total: lines.length }))); + } + return out; } } diff --git a/tests/agent/permission.test.ts b/tests/agent/permission.test.ts index ed1aa69a..cdee1547 100644 --- a/tests/agent/permission.test.ts +++ b/tests/agent/permission.test.ts @@ -78,4 +78,10 @@ describe('planModeDenyReason', () => { expect(planModeDenyReason('get_goal')).toBeNull(); expect(planModeDenyReason('cron_list')).toBeNull(); }); + + it('plan mode deny reason 文案包含 exit_plan_mode 引导', () => { + const reason = planModeDenyReason('write_file'); + expect(reason).toContain('exit_plan_mode'); + expect(reason).toContain('write_file'); + }); }); diff --git a/tests/agent/runTurnParallel.test.ts b/tests/agent/runTurnParallel.test.ts index 7512f60d..d7c7722d 100644 --- a/tests/agent/runTurnParallel.test.ts +++ b/tests/agent/runTurnParallel.test.ts @@ -180,6 +180,34 @@ describe('runTurn 并行工具执行', () => { expect(events.at(-1)!.type).toBe('turn_done'); }); + it('授权拒绝可带 errorCode:plan mode 下返回 PLAN_MODE_BLOCKED', async () => { + const hooks: LoopHooks = { + authorizeToolCall: (req) => + req.name === 'write_file' + ? { decision: 'deny', reason: 'plan mode blocked', errorCode: 'PLAN_MODE_BLOCKED' } + : { decision: 'allow' }, + }; + const { provider } = makeFakeProvider([ + { + textChunks: [], + finalContent: [ + toolUseBlock('c1', 'write_file', { path: 'should-not-exist.txt', content: 'x' }), + ], + }, + { textChunks: ['好的'], finalContent: [textBlock('好的')] }, + ]); + const messages: StoredMessage[] = [sm('go')]; + const events = await collect(runAgent(base(provider, messages, { hooks }))); + + const blocks = toolResultBlocks(messages); + expect(blocks[0]!.is_error).toBe(true); + expect(String(blocks[0]!.content)).toContain('plan mode blocked'); + const ends = events.filter((e) => e.type === 'tool_end') as { id: string; isError: boolean; errorCode?: string }[]; + const end = ends.find((e) => e.id === 'c1'); + expect(end?.isError).toBe(true); + expect(end?.errorCode).toBe('PLAN_MODE_BLOCKED'); + }); + it('异常隔离:单个工具的结果后处理抛异常 → 该槽转 is_error,兄弟任务照常', async () => { const hooks: LoopHooks = { finalizeToolResult: (req, result) => { diff --git a/tests/tui-pi/pickers.test.ts b/tests/tui-pi/pickers.test.ts index f61cdbe8..369ed288 100644 --- a/tests/tui-pi/pickers.test.ts +++ b/tests/tui-pi/pickers.test.ts @@ -190,6 +190,19 @@ describe('PickerOverlay', () => { expect(picked).toEqual(['beta']); }); + it('Windows 终端的 Enter 变体均可确认:\\r / \\n / \\r\\n / SS3-OM', () => { + const cases: string[] = ['\r', '\n', '\x1bOM']; + for (const key of cases) { + const { overlay, picked } = mk(); + overlay.handleInput(key); + expect(picked, `Enter variant ${JSON.stringify(key)}`).toEqual(['alpha']); + } + // CRLF 序列整体送入时也应确认 + const crlf = mk(); + crlf.overlay.handleInput('\r\n'); + expect(crlf.picked).toEqual(['alpha']); + }); + it('可打印字符进过滤串并显示在标题行,退格删字', () => { const { overlay, picked } = mk(); overlay.handleInput('g'); diff --git a/tests/tui-pi/prompts.test.ts b/tests/tui-pi/prompts.test.ts index 161aec10..305f4640 100644 --- a/tests/tui-pi/prompts.test.ts +++ b/tests/tui-pi/prompts.test.ts @@ -172,6 +172,28 @@ describe('PlanApproval', () => { escaped.block.handleInput(ESC); expect(escaped.settled).toEqual([{ approved: false }]); }); + + it('短计划不折叠,底部无折叠提示', () => { + const { block } = mk('# 计划\n\n- 第一步'); + const body = plain(block.render(70)).join('\n'); + expect(body).toContain('计划'); + expect(body).toContain('第一步'); + expect(body).not.toContain('Ctrl+E'); + }); + + it('超长计划默认折叠到前 10 行,Ctrl+E 展开', () => { + const longPlan = '# 计划\n\n' + Array.from({ length: 20 }, (_, i) => `- 步骤 ${i + 1}`).join('\n'); + const { block } = mk(longPlan); + const collapsed = plain(block.render(70)).join('\n'); + expect(collapsed).toContain('步骤 1'); + expect(collapsed).not.toContain('步骤 11'); + expect(collapsed).toContain('Ctrl+E'); + + block.handleInput('\x05'); // Ctrl+E + const expanded = plain(block.render(70)).join('\n'); + expect(expanded).toContain('步骤 11'); + expect(expanded).toContain('步骤 20'); + }); }); describe('QuestionPrompt', () => { diff --git a/tests/tui-pi/render.test.ts b/tests/tui-pi/render.test.ts index 7d553c6f..0a28eb68 100644 --- a/tests/tui-pi/render.test.ts +++ b/tests/tui-pi/render.test.ts @@ -366,6 +366,67 @@ describe('ItemBlock 渲染', () => { expect(lines).toContain('还有 2 行'); }); + it('PLAN_MODE_BLOCKED 渲染为黄色标记 + [plan mode] 标记', () => { + const prev = chalk.level; + chalk.level = 3; + try { + const blocked = new ItemBlock({ + kind: 'tool', + id: 't4', + name: 'write_file', + input: { path: 'src/x.ts' }, + status: 'error', + result: '计划模式(plan mode)已开启:当前只能做只读调查,不能修改文件或执行命令(write_file 被拦截)。请完成调查后调用 exit_plan_mode 提交计划供用户确认。', + errorCode: 'PLAN_MODE_BLOCKED', + } as never).render(60); + const head = blocked[0]!; + // 状态符是黄色(SGR 33)而非红色(SGR 31) + expect(head).toContain('\x1b[33m✗\x1b[39m'); + // 工具名后紧跟 [plan mode] 标记 + expect(head).toContain('[plan mode]'); + // 结果体也是黄色 + const body = blocked.slice(1).join('\n'); + expect(body).toContain('\x1b[33m'); + expect(body).not.toContain('\x1b[31m'); + // Ctrl+O 展开态同样如此 + const expanded = ItemBlock.renderExpanded({ + kind: 'tool', + id: 't4', + name: 'write_file', + input: { path: 'src/x.ts' }, + status: 'error', + result: '计划模式(plan mode)已开启:当前只能做只读调查,不能修改文件或执行命令(write_file 被拦截)。请完成调查后调用 exit_plan_mode 提交计划供用户确认。', + errorCode: 'PLAN_MODE_BLOCKED', + } as never, 60); + expect(expanded[0]!).toContain('\x1b[33m✗\x1b[39m'); + expect(expanded[0]!).toContain('[plan mode]'); + } finally { + chalk.level = prev; + } + }); + + it('普通错误工具仍为红色 ✗,不受 errorCode 映射影响', () => { + const prev = chalk.level; + chalk.level = 3; + try { + const err = new ItemBlock({ + kind: 'tool', + id: 't5', + name: 'bash', + input: { command: 'npm test' }, + status: 'error', + result: 'command not found', + } as never).render(60); + const head = err[0]!; + expect(head).toContain('\x1b[31m✗\x1b[39m'); + expect(head).not.toContain('[plan mode]'); + const body = err.slice(1).join('\n'); + expect(body).toContain('\x1b[31m'); + } finally { + chalk.level = prev; + } + }); + it('内容未变时复用缓存数组(同一引用),换内容后失效', () => { const b = new ItemBlock({ kind: 'assistant', text: 'x' }); const first = b.render(40);