diff --git a/package-lock.json b/package-lock.json index 8226746e66e..026df7d625d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -236,6 +236,7 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -711,6 +712,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -734,6 +736,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2175,6 +2178,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3597,6 +3601,7 @@ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", @@ -4068,6 +4073,7 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -4078,6 +4084,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -4283,6 +4290,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -4528,6 +4536,7 @@ "integrity": "sha512-tJxiPrWmzH8a+w9nLKlQMzAKX/7VjFs50MWgcAj7p9XQ7AQ9/35fByFYptgPELyLw+0aixTnC4pUWV+APcZ/kw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@testing-library/dom": "^10.4.0", "@testing-library/user-event": "^14.6.1", @@ -4678,6 +4687,7 @@ "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "pathe": "^2.0.3", @@ -4851,6 +4861,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5265,8 +5276,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/array-includes": { "version": "3.1.9", @@ -5814,6 +5824,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -6474,7 +6485,6 @@ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", "license": "MIT", - "peer": true, "dependencies": { "safe-buffer": "5.2.1" }, @@ -7552,6 +7562,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8256,7 +8267,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -8318,7 +8328,6 @@ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -8328,7 +8337,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8338,7 +8346,6 @@ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -8546,7 +8553,6 @@ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", @@ -8565,7 +8571,6 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", "license": "MIT", - "peer": true, "dependencies": { "ms": "2.0.0" } @@ -8574,15 +8579,13 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/finalhandler/node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.8" } @@ -9639,6 +9642,7 @@ "resolved": "https://registry.npmjs.org/ink/-/ink-6.2.3.tgz", "integrity": "sha512-fQkfEJjKbLXIcVWEE3MvpYSnwtbbmRsmeNDNz1pIuOFlwE+UF2gsy228J36OXKZGWJWZJKUigphBSqCNMcARtg==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.0", "ansi-escapes": "^7.0.0", @@ -10616,6 +10620,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -11495,7 +11500,6 @@ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.6" } @@ -12678,8 +12682,7 @@ "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/path-type": { "version": "3.0.0", @@ -12842,7 +12845,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -12877,6 +12879,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -13036,6 +13039,7 @@ "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -13351,6 +13355,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -13361,6 +13366,7 @@ "integrity": "sha512-cq/o30z9W2Wb4rzBefjv5fBalHU0rJGZCHAkf/RHSBWSSYwh8PlQTqqOJmgIIbBtpj27T6FIPXeomIjZtCNVqA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -13438,6 +13444,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -14621,6 +14628,7 @@ "integrity": "sha512-fIQnFtpksRRgHR1CO1onGX3djaog4qsW/c5U8arqYTkUEr2TaWpn05mIJDOBoPJFlOdqFrB4Ttv0PZJxV7avhw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -15309,6 +15317,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -15508,7 +15517,8 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -15516,6 +15526,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -15674,6 +15685,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -15997,7 +16009,6 @@ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", "license": "MIT", - "peer": true, "engines": { "node": ">= 0.4.0" } @@ -16040,6 +16051,7 @@ "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", @@ -16153,6 +16165,7 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16166,6 +16179,7 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -16684,6 +16698,7 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", + "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -16854,6 +16869,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -17024,6 +17040,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -17682,6 +17699,7 @@ "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz", "integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==", "license": "MIT", + "peer": true, "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", @@ -18076,6 +18094,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -18838,6 +18857,7 @@ "integrity": "sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==", "dev": true, "license": "BSD-2-Clause", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "7.18.0", "@typescript-eslint/types": "7.18.0", @@ -19318,6 +19338,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -20444,6 +20465,7 @@ "integrity": "sha512-Ljb1cnSJSivGN0LqXd/zmDbWEM0RNNg2t1QW/XUhYl/qPqyu7CsqeWtqQXHVaJsecLPuDoak2oJcZN2QoRIOag==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@vitest/expect": "1.6.1", "@vitest/runner": "1.6.1", @@ -21680,6 +21702,7 @@ "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -22653,6 +22676,7 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22667,6 +22691,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts index bd8a038e538..210246178a7 100644 --- a/packages/cli/src/nonInteractiveCli.test.ts +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -29,7 +29,10 @@ import type { LoadedSettings } from './config/settings.js'; import { CommandKind, type ExecutionMode } from './ui/commands/types.js'; import { filterCommandsForMode } from './services/commandUtils.js'; import { _resetCleanupFunctionsForTest } from './utils/cleanup.js'; -import { AlreadyReportedError, _resetExitLatchForTest } from './utils/errors.js'; +import { + AlreadyReportedError, + _resetExitLatchForTest, +} from './utils/errors.js'; // Mock core modules vi.mock('./ui/hooks/atCommandProcessor.js'); diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index 1145e08c110..e3d955a242b 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -71,6 +71,7 @@ describe('clearCommand', () => { }), getBackgroundShellRegistry: vi.fn().mockReturnValue({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: mockResetBackgroundShells, abortAll: mockAbortBackgroundShells, }), @@ -307,6 +308,7 @@ describe('clearCommand', () => { }), getBackgroundShellRegistry: vi.fn().mockReturnValue({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: mockResetBackgroundShells, abortAll: mockAbortBackgroundShells, }), @@ -380,6 +382,7 @@ describe('clearCommand', () => { }), getBackgroundShellRegistry: vi.fn().mockReturnValue({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: vi.fn(), }), getMonitorRegistry: vi.fn().mockReturnValue({ @@ -430,6 +433,7 @@ describe('clearCommand', () => { }), getBackgroundShellRegistry: vi.fn().mockReturnValue({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: vi.fn(), }), getMonitorRegistry: vi.fn().mockReturnValue({ @@ -470,5 +474,61 @@ describe('clearCommand', () => { expect(mockStartNewSession).not.toHaveBeenCalled(); expect(mockResetChat).not.toHaveBeenCalled(); }); + + it('blocks session clearing while a background shell is still running', async () => { + if (!clearCommand.action) + throw new Error('clearCommand must have an action.'); + + const blockedContext = createMockCommandContext({ + executionMode: 'non_interactive', + services: { + config: { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + hasUnfinalizedTasks: vi.fn().mockReturnValue(false), + reset: vi.fn(), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([ + { + shellId: 'shell_123', + status: 'running', + }, + ]), + hasRunningEntries: vi.fn().mockReturnValue(true), + reset: vi.fn(), + }), + getMonitorRegistry: vi.fn().mockReturnValue({ + getRunning: vi.fn().mockReturnValue([]), + reset: vi.fn(), + }), + getHookSystem: mockGetHookSystem, + startNewSession: mockStartNewSession, + getGeminiClient: vi.fn().mockReturnValue({ + resetChat: mockResetChat, + } as unknown as GeminiClient), + getModel: vi.fn().mockReturnValue('test-model'), + getApprovalMode: vi.fn().mockReturnValue('default'), + getToolRegistry: vi.fn().mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([]), + }), + getDebugLogger: vi.fn().mockReturnValue({ warn: vi.fn() }), + }, + }, + session: { + startNewSession: vi.fn(), + }, + }); + + const result = await clearCommand.action(blockedContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + "Stop the current session's running background tasks before starting a new session.", + }); + expect(mockStartNewSession).not.toHaveBeenCalled(); + expect(mockResetChat).not.toHaveBeenCalled(); + }); }); }); diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index 1a954d06721..ee9a9d6ed6d 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -12,26 +12,12 @@ import { SessionEndReason, SessionStartSource, ToolNames, - type Config, type PermissionMode, } from '@qwen-code/qwen-code-core'; - -function hasBlockingBackgroundWork(config: Config): boolean { - return ( - config.getBackgroundTaskRegistry().hasUnfinalizedTasks() || - config.getMonitorRegistry().getRunning().length > 0 || - config - .getBackgroundShellRegistry() - .getAll() - .some((entry) => entry.status === 'running') - ); -} - -function resetBackgroundStateForSessionSwitch(config: Config): void { - config.getBackgroundTaskRegistry().reset(); - config.getMonitorRegistry().reset(); - config.getBackgroundShellRegistry().reset(); -} +import { + hasBlockingBackgroundWork, + resetBackgroundStateForSessionSwitch, +} from '../utils/backgroundWorkUtils.js'; export const clearCommand: SlashCommand = { name: 'clear', diff --git a/packages/cli/src/ui/hooks/useResumeCommand.test.ts b/packages/cli/src/ui/hooks/useResumeCommand.test.ts index afad6b21789..5ce00718c25 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.test.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.test.ts @@ -163,6 +163,7 @@ describe('useResumeCommand', () => { }), getBackgroundShellRegistry: () => ({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: vi.fn(), }), getMonitorRegistry: () => ({ @@ -248,6 +249,7 @@ describe('useResumeCommand', () => { }), getBackgroundShellRegistry: () => ({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: vi.fn(), }), getMonitorRegistry: () => ({ @@ -306,6 +308,7 @@ describe('useResumeCommand', () => { }), getBackgroundShellRegistry: () => ({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: vi.fn(), }), getMonitorRegistry: () => ({ @@ -364,6 +367,7 @@ describe('useResumeCommand', () => { }), getBackgroundShellRegistry: () => ({ getAll: vi.fn().mockReturnValue([]), + hasRunningEntries: vi.fn().mockReturnValue(false), reset: vi.fn(), }), getMonitorRegistry: () => ({ diff --git a/packages/cli/src/ui/hooks/useResumeCommand.ts b/packages/cli/src/ui/hooks/useResumeCommand.ts index 0a5e9e6a2e1..189bef8355a 100644 --- a/packages/cli/src/ui/hooks/useResumeCommand.ts +++ b/packages/cli/src/ui/hooks/useResumeCommand.ts @@ -15,6 +15,10 @@ import { import { buildResumedHistoryItems } from '../utils/resumeHistoryUtils.js'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; import { MessageType, type HistoryItem } from '../types.js'; +import { + hasBlockingBackgroundWork, + resetBackgroundStateForSessionSwitch, +} from '../utils/backgroundWorkUtils.js'; export interface UseResumeCommandOptions { config: Config | null; @@ -45,23 +49,6 @@ export interface UseResumeCommandResult { const BACKGROUND_WORK_SWITCH_BLOCKED_MESSAGE = "Stop the current session's running background tasks before resuming another session."; -function hasBlockingBackgroundWork(config: Config): boolean { - return ( - config.getBackgroundTaskRegistry().hasUnfinalizedTasks() || - config.getMonitorRegistry().getRunning().length > 0 || - config - .getBackgroundShellRegistry() - .getAll() - .some((entry) => entry.status === 'running') - ); -} - -function resetBackgroundStateForSessionSwitch(config: Config): void { - config.getBackgroundTaskRegistry().reset(); - config.getMonitorRegistry().reset(); - config.getBackgroundShellRegistry().reset(); -} - export function useResumeCommand( options?: UseResumeCommandOptions, ): UseResumeCommandResult { diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts new file mode 100644 index 00000000000..cee3841248a --- /dev/null +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.test.ts @@ -0,0 +1,116 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import type { Config } from '@qwen-code/qwen-code-core'; +import { + hasBlockingBackgroundWork, + resetBackgroundStateForSessionSwitch, +} from './backgroundWorkUtils.js'; + +function createMockConfig(overrides?: { + hasUnfinalizedTasks?: boolean; + runningMonitors?: unknown[]; + hasRunningEntries?: boolean; +}): Config { + return { + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: () => overrides?.hasUnfinalizedTasks ?? false, + reset: vi.fn(), + }), + getMonitorRegistry: () => ({ + getRunning: () => overrides?.runningMonitors ?? [], + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => ({ + hasRunningEntries: () => overrides?.hasRunningEntries ?? false, + reset: vi.fn(), + }), + } as unknown as Config; +} + +describe('hasBlockingBackgroundWork', () => { + it('returns false when nothing is running', () => { + expect(hasBlockingBackgroundWork(createMockConfig())).toBe(false); + }); + + it('returns true when background tasks are unfinalized', () => { + expect( + hasBlockingBackgroundWork( + createMockConfig({ hasUnfinalizedTasks: true }), + ), + ).toBe(true); + }); + + it('returns true when monitors are running', () => { + expect( + hasBlockingBackgroundWork( + createMockConfig({ runningMonitors: [{ id: 'm1' }] }), + ), + ).toBe(true); + }); + + it('returns true when shell entries are running', () => { + expect( + hasBlockingBackgroundWork(createMockConfig({ hasRunningEntries: true })), + ).toBe(true); + }); + + it('short-circuits: does not check monitors or shells when tasks are unfinalized', () => { + const config = { + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: () => true, + reset: vi.fn(), + }), + getMonitorRegistry: () => { + throw new Error('should not be called'); + }, + getBackgroundShellRegistry: () => { + throw new Error('should not be called'); + }, + } as unknown as Config; + + expect(hasBlockingBackgroundWork(config)).toBe(true); + }); + + it('short-circuits: does not check shells when monitors are running', () => { + const config = { + getBackgroundTaskRegistry: () => ({ + hasUnfinalizedTasks: () => false, + reset: vi.fn(), + }), + getMonitorRegistry: () => ({ + getRunning: () => [{ id: 'm1' }], + reset: vi.fn(), + }), + getBackgroundShellRegistry: () => { + throw new Error('should not be called'); + }, + } as unknown as Config; + + expect(hasBlockingBackgroundWork(config)).toBe(true); + }); +}); + +describe('resetBackgroundStateForSessionSwitch', () => { + it('calls reset on all three registries', () => { + const resetTasks = vi.fn(); + const resetMonitors = vi.fn(); + const resetShells = vi.fn(); + + const config = { + getBackgroundTaskRegistry: () => ({ reset: resetTasks }), + getMonitorRegistry: () => ({ reset: resetMonitors }), + getBackgroundShellRegistry: () => ({ reset: resetShells }), + } as unknown as Config; + + resetBackgroundStateForSessionSwitch(config); + + expect(resetTasks).toHaveBeenCalledOnce(); + expect(resetMonitors).toHaveBeenCalledOnce(); + expect(resetShells).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/cli/src/ui/utils/backgroundWorkUtils.ts b/packages/cli/src/ui/utils/backgroundWorkUtils.ts new file mode 100644 index 00000000000..9e7b4d71c9f --- /dev/null +++ b/packages/cli/src/ui/utils/backgroundWorkUtils.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Code + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@qwen-code/qwen-code-core'; + +export function hasBlockingBackgroundWork(config: Config): boolean { + return ( + config.getBackgroundTaskRegistry().hasUnfinalizedTasks() || + config.getMonitorRegistry().getRunning().length > 0 || + config.getBackgroundShellRegistry().hasRunningEntries() + ); +} + +export function resetBackgroundStateForSessionSwitch(config: Config): void { + config.getBackgroundTaskRegistry().reset(); + config.getMonitorRegistry().reset(); + config.getBackgroundShellRegistry().reset(); +} diff --git a/packages/cli/src/utils/errors.test.ts b/packages/cli/src/utils/errors.test.ts index 4fcb0301eff..3f91fd4b642 100644 --- a/packages/cli/src/utils/errors.test.ts +++ b/packages/cli/src/utils/errors.test.ts @@ -267,7 +267,8 @@ describe('errors', () => { { error: { type: 'AlreadyReportedError', - message: '[API Error: 402 Model X is not available for billing.]', + message: + '[API Error: 402 Model X is not available for billing.]', code: 42, }, }, diff --git a/packages/core/src/permissions/permission-manager.ts b/packages/core/src/permissions/permission-manager.ts index fd71399890d..68a6ce1e727 100644 --- a/packages/core/src/permissions/permission-manager.ts +++ b/packages/core/src/permissions/permission-manager.ts @@ -10,6 +10,7 @@ import { matchesRule, resolveToolName, splitCompoundCommand, + SHELL_TOOL_NAMES, } from './rule-parser.js'; import type { PathMatchContext } from './rule-parser.js'; import { extractShellOperations } from './shell-semantics.js'; @@ -32,13 +33,6 @@ import type { const debugLogger = createDebugLogger('PERMISSIONS'); -/** - * Tools that spawn shell commands and share the same permission evaluation - * semantics: compound-command splitting, AST read-only analysis, and - * virtual file/network operation matching. - */ -const SHELL_LIKE_TOOLS = new Set(['run_shell_command', 'monitor']); - /** * Numeric priority for each PermissionDecision. * Higher number = more restrictive. Used to combine decisions by taking @@ -189,7 +183,7 @@ export class PermissionManager { // a concrete permission (deny/ask/allow) based on the command's readonly status. if ( decision === 'default' && - SHELL_LIKE_TOOLS.has(toolName) && + SHELL_TOOL_NAMES.has(toolName) && command !== undefined ) { return this.resolveDefaultPermission(command); @@ -267,7 +261,7 @@ export class PermissionManager { // must never downgrade an explicit 'allow' decision from a Bash rule. // Example: `git status` has no file ops; an allow rule for `Bash(git *)` // should return 'allow', not be downgraded to 'default'. - if (SHELL_LIKE_TOOLS.has(toolName) && command !== undefined) { + if (SHELL_TOOL_NAMES.has(toolName) && command !== undefined) { const cwd = pathCtx?.cwd ?? process.cwd(); const virtualDecision = this.evaluateShellVirtualOps( extractShellOperations(command, cwd), @@ -584,7 +578,7 @@ export class PermissionManager { ctx = this.normalizePermissionContext(ctx); const { toolName, command, cwd, filePath, domain, specifier } = ctx; - if (SHELL_LIKE_TOOLS.has(ctx.toolName) && command !== undefined) { + if (SHELL_TOOL_NAMES.has(ctx.toolName) && command !== undefined) { const subCommands = splitCompoundCommand(command); if (subCommands.length > 1) { return subCommands.some((subCmd) => @@ -625,7 +619,7 @@ export class PermissionManager { // extracted from the command has a relevant rule. This ensures the PM is // consulted (and the confirmation dialog shown) when Read/Edit/etc. rules // would match equivalent shell commands. - if (SHELL_LIKE_TOOLS.has(ctx.toolName) && ctx.command !== undefined) { + if (SHELL_TOOL_NAMES.has(ctx.toolName) && ctx.command !== undefined) { const cwd = pathCtx?.cwd ?? process.cwd(); const ops = extractShellOperations(ctx.command, cwd); if ( @@ -661,7 +655,7 @@ export class PermissionManager { ctx = this.normalizePermissionContext(ctx); const { toolName, command, cwd, filePath, domain, specifier } = ctx; - if (SHELL_LIKE_TOOLS.has(ctx.toolName) && command !== undefined) { + if (SHELL_TOOL_NAMES.has(ctx.toolName) && command !== undefined) { const subCommands = splitCompoundCommand(command); if (subCommands.length > 1) { return subCommands.some((subCmd) => @@ -693,7 +687,7 @@ export class PermissionManager { return true; } - if (SHELL_LIKE_TOOLS.has(ctx.toolName) && ctx.command !== undefined) { + if (SHELL_TOOL_NAMES.has(ctx.toolName) && ctx.command !== undefined) { const cwd = pathCtx?.cwd ?? process.cwd(); const ops = extractShellOperations(ctx.command, cwd); return ops.some((op) => { diff --git a/packages/core/src/permissions/rule-parser.ts b/packages/core/src/permissions/rule-parser.ts index bf3d3534cdf..2e6ea456ad7 100644 --- a/packages/core/src/permissions/rule-parser.ts +++ b/packages/core/src/permissions/rule-parser.ts @@ -130,7 +130,10 @@ export const TOOL_NAME_ALIASES: Readonly> = { /** * Shell tool canonical names. These use command-style rule specifiers. */ -const SHELL_TOOL_NAMES = new Set(['run_shell_command', 'monitor']); +export const SHELL_TOOL_NAMES: ReadonlySet = new Set([ + 'run_shell_command', + 'monitor', +]); /** * File-reading tools — "Read" rules apply to all of these (best-effort). diff --git a/packages/core/src/services/monitorRegistry.test.ts b/packages/core/src/services/monitorRegistry.test.ts index dc09ec70e17..87345a3533f 100644 --- a/packages/core/src/services/monitorRegistry.test.ts +++ b/packages/core/src/services/monitorRegistry.test.ts @@ -585,6 +585,21 @@ describe('MonitorRegistry', () => { ).not.toThrow(); }); + it('includes droppedLines count in terminal notification text', () => { + const callback = vi.fn(); + registry.setNotificationCallback(callback); + const entry = createEntry(); + registry.register(entry); + + // Simulate throttle drops (droppedLines is incremented by Monitor tool) + entry.droppedLines = 5; + registry.complete('mon-1', 0); + + const [displayText, modelText] = callback.mock.calls[0] as [string, string]; + expect(displayText).toContain('5 lines dropped due to throttling'); + expect(modelText).toContain('5 lines dropped due to throttling'); + }); + describe('setStatusChangeCallback', () => { it('fires once on register (nothing → running)', () => { const cb = vi.fn(); diff --git a/packages/core/src/tools/monitor.test.ts b/packages/core/src/tools/monitor.test.ts index 318a59fe9c9..67ed819a9a0 100644 --- a/packages/core/src/tools/monitor.test.ts +++ b/packages/core/src/tools/monitor.test.ts @@ -390,6 +390,26 @@ describe('MonitorTool', () => { 'Monitor(tail -f /tmp/app.log)', ]); }); + + it('keeps sub-command in confirmation scope when AST read-only check fails', async () => { + mockIsShellCommandReadOnlyAST.mockRejectedValueOnce( + new Error('AST parse failure'), + ); + + const invocation = createInvocation({ + command: 'tail -f /tmp/app.log', + }); + + const details = (await invocation.getConfirmationDetails( + new AbortController().signal, + )) as ToolCallConfirmationDetails & { + permissionRules?: string[]; + }; + + // Sub-command should still be in confirmation scope (not dropped) + expect(details.permissionRules).toBeDefined(); + expect(details.permissionRules!.length).toBeGreaterThan(0); + }); }); describe('getDefaultPermission', () => { @@ -983,6 +1003,30 @@ describe('MonitorTool', () => { expect(all[0].status).toBe('failed'); }); + it('settles as completed when exit and close both report null code and null signal', async () => { + const callback = vi.fn(); + monitorRegistry.setNotificationCallback(callback); + + const invocation = createInvocation({ + command: 'some-cmd', + }); + + await invocation.execute(new AbortController().signal); + mockChild._emitExit(null, null); + mockChild._emitClose(null, null); + + const all = monitorRegistry.getAll(); + expect(all[0].status).toBe('completed'); + // Terminal notification should not include a result tag (exitCode is null) + const terminalCall = callback.mock.calls.find( + (args) => + typeof args[1] === 'string' && + (args[1] as string).includes('completed'), + ); + expect(terminalCall).toBeDefined(); + expect(terminalCall![1]).not.toContain(''); + }); + it('does not kill monitor on turn signal abort', async () => { const turnAc = new AbortController(); const invocation = createInvocation({ @@ -1227,6 +1271,56 @@ describe('MonitorTool', () => { vi.useRealTimers(); } }); + + it('recovers token bucket when clock moves backwards (suspend/resume)', async () => { + const realDateNow = Date.now; + let mockTime = 0; + Date.now = () => mockTime; + try { + mockTime = 0; + const callback = vi.fn(); + monitorRegistry.setNotificationCallback(callback); + + const invocation = createInvocation({ command: 'noisy-cmd' }); + await invocation.execute(new AbortController().signal); + + // Burn the entire burst at t=0. + mockChild.stdout.emit('data', Buffer.from('l1\nl2\nl3\nl4\nl5\n')); + expect(callback).toHaveBeenCalledTimes(5); + + // Advance to t=5000, drain 5 refilled tokens, then confirm bucket + // is empty by emitting a line that gets dropped. + mockTime = 5000; + mockChild.stdout.emit('data', Buffer.from('l6\nl7\nl8\nl9\nl10\n')); + expect(callback).toHaveBeenCalledTimes(10); + mockChild.stdout.emit('data', Buffer.from('l11\n')); + expect(callback).toHaveBeenCalledTimes(10); // l11 dropped + + // Simulate clock going backwards (suspend/resume, NTP rollback). + // At this point lastRefill=5000. Setting time to 2000 makes + // elapsed = 2000-5000 = -3000. Without the guard, the bucket + // stays starved. With the guard, lastRefill resets to 2000. + mockTime = 2000; + + // Emit while clock is in the past — guard has just reset + // lastRefill to 2000, so elapsed = 0, no refill, bucket still 0. + // l12a is dropped (confirming bucket is empty after the reset). + mockChild.stdout.emit('data', Buffer.from('l12a\n')); + expect(callback).toHaveBeenCalledTimes(10); + + // Advance 1s past the reset point — one token refills. + mockTime = 3000; + mockChild.stdout.emit('data', Buffer.from('l12b\n')); + expect(callback).toHaveBeenCalledTimes(11); + + // This final assertion is the regression check: without the guard, + // lastRefill would still be 5000, elapsed = 3000-5000 = -2000, + // and l12b would be dropped (callback still 10). + } finally { + Date.now = realDateNow; + monitorRegistry.abortAll({ notify: false }); + } + }); }); }); diff --git a/packages/core/src/tools/monitor.ts b/packages/core/src/tools/monitor.ts index 7a151ed9501..1960cc5d19f 100644 --- a/packages/core/src/tools/monitor.ts +++ b/packages/core/src/tools/monitor.ts @@ -201,9 +201,13 @@ class MonitorToolInvocation extends BaseToolInvocation< let isReadOnly = false; try { isReadOnly = await isShellCommandReadOnlyAST(sub); - } catch { + } catch (e) { // Conservative fallback: if AST analysis fails, keep the sub-command // in the confirmation scope instead of accidentally dropping it. + debugLogger.warn( + 'AST read-only check failed for monitor sub-command, falling back to ask:', + e, + ); } if (isReadOnly) { @@ -364,10 +368,23 @@ class MonitorToolInvocation extends BaseToolInvocation< // Refill tokens const now = Date.now(); const elapsed = now - lastRefill; - const newTokens = Math.floor(elapsed / THROTTLE_REFILL_INTERVAL_MS); - if (newTokens > 0) { - tokenBucket = Math.min(THROTTLE_BURST_SIZE, tokenBucket + newTokens); - lastRefill += newTokens * THROTTLE_REFILL_INTERVAL_MS; + if (elapsed < 0) { + // Clock went backwards (suspend/resume, NTP); reset to avoid + // starving the bucket until the clock catches up. + // Note: logged to debug file only; no operator-visible output. + // If throttled line drops are observed without an active debug + // session, clock anomaly vs. genuine rate limiting cannot be + // distinguished from the notification alone. + debugLogger.warn( + `Monitor ${monitorId}: clock moved backwards by ${-elapsed}ms, resetting refill timestamp`, + ); + lastRefill = now; + } else { + const newTokens = Math.floor(elapsed / THROTTLE_REFILL_INTERVAL_MS); + if (newTokens > 0) { + tokenBucket = Math.min(THROTTLE_BURST_SIZE, tokenBucket + newTokens); + lastRefill += newTokens * THROTTLE_REFILL_INTERVAL_MS; + } } if (tokenBucket > 0) { diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx index ea31c1a698b..9e9359d76f2 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.test.tsx @@ -14,10 +14,10 @@ import { ToolCallRouter } from './index.js'; vi.mock('@qwen-code/webui', async () => { const React = await vi.importActual('react'); + // Use a data attribute to record which component was selected by the + // *real* routing logic, rather than maintaining a parallel mock router. const renderLabel = (label: string) => - function MockTool({ - toolCall, - }: { + function MockTool(props: { toolCall: { title?: string; rawOutput?: { @@ -25,29 +25,68 @@ vi.mock('@qwen-code/webui', async () => { terminateReason?: string; }; }; + isFirst?: boolean; + isLast?: boolean; }) { return React.createElement( 'div', - undefined, - `${label}:${toolCall.rawOutput?.taskDescription || toolCall.title || ''}:${toolCall.rawOutput?.terminateReason || ''}`, + { + 'data-label': label, + 'data-is-first': props.isFirst, + 'data-is-last': props.isLast, + }, + `${label}:${props.toolCall.rawOutput?.taskDescription || props.toolCall.title || ''}:${props.toolCall.rawOutput?.terminateReason || ''}`, ); }; - return { - shouldShowToolCall: () => true, - isAgentExecutionToolCall: (toolCall: { rawOutput?: { type?: string } }) => - toolCall.rawOutput?.type === 'task_execution', + // Import the real routing function so the test validates actual routing + // rather than a manually-maintained parallel mock that can silently drift. + const { + getToolCallComponent: realGetToolCallComponent, + isAgentExecutionToolCall, + } = + await vi.importActual( + '@qwen-code/webui', + ); + + // Map each real component to its label-based mock. + const componentMocks: Record> = { + AgentToolCall: renderLabel('agent'), GenericToolCall: renderLabel('generic'), + ReadToolCall: renderLabel('read'), + ShellToolCall: renderLabel('shell'), ThinkToolCall: renderLabel('think'), - SaveMemoryToolCall: renderLabel('memory'), EditToolCall: renderLabel('edit'), WriteToolCall: renderLabel('write'), SearchToolCall: renderLabel('search'), UpdatedPlanToolCall: renderLabel('plan'), - ShellToolCall: renderLabel('shell'), - ReadToolCall: renderLabel('read'), WebFetchToolCall: renderLabel('web'), - AgentToolCall: renderLabel('agent'), + }; + + // Wrap getToolCallComponent to return the label-mock instead of the real + // component — the routing logic is real, only the rendering is mocked. + const getToolCallComponent = ( + toolCall: Parameters[0], + ) => { + const realComponent = realGetToolCallComponent(toolCall); + const componentName = realComponent.displayName || realComponent.name || ''; + return componentMocks[componentName] || componentMocks['GenericToolCall']!; + }; + + return { + shouldShowToolCall: () => true, + isAgentExecutionToolCall, + getToolCallComponent, + GenericToolCall: componentMocks['GenericToolCall'], + ThinkToolCall: componentMocks['ThinkToolCall'], + EditToolCall: componentMocks['EditToolCall'], + WriteToolCall: componentMocks['WriteToolCall'], + SearchToolCall: componentMocks['SearchToolCall'], + UpdatedPlanToolCall: componentMocks['UpdatedPlanToolCall'], + ShellToolCall: componentMocks['ShellToolCall'], + ReadToolCall: componentMocks['ReadToolCall'], + WebFetchToolCall: componentMocks['WebFetchToolCall'], + AgentToolCall: componentMocks['AgentToolCall'], }; }); @@ -148,4 +187,28 @@ describe('ToolCallRouter agent execution rendering', () => { 'agent:Explore auth logic:Subagent crashed', ); }); + + it('forwards isFirst and isLast props to the underlying component', () => { + act(() => { + root?.render( + , + ); + }); + + const renderedDiv = container?.querySelector('div'); + expect(renderedDiv?.getAttribute('data-label')).toBe('read'); + expect(renderedDiv?.getAttribute('data-is-first')).toBe('true'); + expect(renderedDiv?.getAttribute('data-is-last')).toBe('false'); + }); }); diff --git a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx index 14d7376f593..a778de66062 100644 --- a/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx +++ b/packages/vscode-ide-companion/src/webview/components/messages/toolcalls/index.tsx @@ -7,89 +7,18 @@ * All UI components are now imported from @qwen-code/webui */ +import { shouldShowToolCall, getToolCallComponent } from '@qwen-code/webui'; import type { FC } from 'react'; -import { - shouldShowToolCall, - // All ToolCall components from webui - AgentToolCall, - isAgentExecutionToolCall, - GenericToolCall, - ThinkToolCall, - EditToolCall, - WriteToolCall, - SearchToolCall, - UpdatedPlanToolCall, - ShellToolCall, - ReadToolCall, - WebFetchToolCall, -} from '@qwen-code/webui'; -import type { BaseToolCallProps, ToolCallData } from '@qwen-code/webui'; - -/** - * Factory function that returns the appropriate tool call component based on kind - */ -export const getToolCallComponent = ( - toolCall: ToolCallData, -): FC => { - if (isAgentExecutionToolCall(toolCall)) { - return AgentToolCall; - } - - const normalizedKind = toolCall.kind.toLowerCase(); - - // Route to specialized components - switch (normalizedKind) { - case 'read': - case 'read_file': - case 'read_many_files': - case 'readmanyfiles': - case 'list_directory': - case 'listfiles': - return ReadToolCall; - - case 'write': - return WriteToolCall; - - case 'edit': - return EditToolCall; - - case 'execute': - case 'bash': - case 'command': - return ShellToolCall; - - case 'updated_plan': - case 'updatedplan': - case 'todo_write': - case 'update_todos': - case 'todowrite': - return UpdatedPlanToolCall; - - case 'search': - case 'grep': - case 'glob': - case 'find': - return SearchToolCall; - - case 'think': - case 'thinking': - return ThinkToolCall; - - case 'fetch': - case 'web_fetch': - case 'webfetch': - return WebFetchToolCall; - - default: - // Fallback to generic component - return GenericToolCall; - } -}; +import type { BaseToolCallProps } from '@qwen-code/webui'; /** * Main tool call component that routes to specialized implementations */ -export const ToolCallRouter: React.FC = ({ toolCall }) => { +export const ToolCallRouter: FC = ({ + toolCall, + isFirst, + isLast, +}) => { // Check if we should show this tool call (hide internal ones) if (!shouldShowToolCall(toolCall.kind)) { return null; @@ -99,7 +28,7 @@ export const ToolCallRouter: React.FC = ({ toolCall }) => { const Component = getToolCallComponent(toolCall); // Render the specialized component - return ; + return ; }; // Re-export types for convenience diff --git a/packages/webui/src/components/ChatViewer/ChatViewer.tsx b/packages/webui/src/components/ChatViewer/ChatViewer.tsx index 8263e92469c..a753903fcb0 100644 --- a/packages/webui/src/components/ChatViewer/ChatViewer.tsx +++ b/packages/webui/src/components/ChatViewer/ChatViewer.tsx @@ -15,18 +15,8 @@ import { UserMessage } from '../messages/UserMessage.js'; import { AssistantMessage } from '../messages/Assistant/AssistantMessage.js'; import { ThinkingMessage } from '../messages/ThinkingMessage.js'; import { - AgentToolCall, - GenericToolCall, - ThinkToolCall, - EditToolCall, - WriteToolCall, - SearchToolCall, - UpdatedPlanToolCall, - ShellToolCall, - ReadToolCall, - WebFetchToolCall, shouldShowToolCall, - isAgentExecutionToolCall, + getToolCallComponent, } from '../toolcalls/index.js'; import type { ToolCallData as BaseToolCallData } from '../toolcalls/index.js'; import './ChatViewer.css'; @@ -144,56 +134,6 @@ function parseTimestamp(isoString: string): number { return isNaN(date.getTime()) ? Date.now() : date.getTime(); } -/** - * Get the appropriate tool call component based on kind - */ -function getToolCallComponent(toolCall: BaseToolCallData) { - if (isAgentExecutionToolCall(toolCall)) { - return AgentToolCall; - } - - const normalizedKind = toolCall.kind.toLowerCase(); - - switch (normalizedKind) { - case 'read': - case 'read_file': - case 'read_many_files': - case 'readmanyfiles': - case 'list_directory': - case 'listfiles': - return ReadToolCall; - case 'write': - return WriteToolCall; - case 'edit': - return EditToolCall; - case 'execute': - case 'bash': - case 'command': - return ShellToolCall; - case 'updated_plan': - case 'updatedplan': - case 'todo_write': - case 'update_todos': - case 'todowrite': - return UpdatedPlanToolCall; - case 'search': - case 'grep': - case 'glob': - case 'find': - return SearchToolCall; - case 'think': - case 'thinking': - return ThinkToolCall; - case 'fetch': - case 'web_fetch': - case 'webfetch': - case 'web_search': // compatibility alias for legacy persisted tool-call records - return WebFetchToolCall; - default: - return GenericToolCall; - } -} - /** * ChatViewer - A standalone component for displaying chat conversations * diff --git a/packages/webui/src/components/toolcalls/index.ts b/packages/webui/src/components/toolcalls/index.ts index 9b292d21397..8c4920ee022 100644 --- a/packages/webui/src/components/toolcalls/index.ts +++ b/packages/webui/src/components/toolcalls/index.ts @@ -22,5 +22,6 @@ export { UpdatedPlanToolCall } from './UpdatedPlanToolCall.js'; export { ShellToolCall } from './ShellToolCall.js'; export { ReadToolCall } from './ReadToolCall.js'; export { WebFetchToolCall } from './WebFetchToolCall.js'; +export { getToolCallComponent } from './routing.js'; export { CheckboxDisplay } from './CheckboxDisplay.js'; export type { CheckboxDisplayProps } from './CheckboxDisplay.js'; diff --git a/packages/webui/src/components/toolcalls/routing.test.ts b/packages/webui/src/components/toolcalls/routing.test.ts new file mode 100644 index 00000000000..0ea4e520e6f --- /dev/null +++ b/packages/webui/src/components/toolcalls/routing.test.ts @@ -0,0 +1,101 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { getToolCallComponent } from './routing.js'; +import { AgentToolCall } from './AgentToolCall.js'; +import { GenericToolCall } from './GenericToolCall.js'; +import { ReadToolCall } from './ReadToolCall.js'; +import { WriteToolCall } from './WriteToolCall.js'; +import { EditToolCall } from './EditToolCall.js'; +import { ShellToolCall } from './ShellToolCall.js'; +import { UpdatedPlanToolCall } from './UpdatedPlanToolCall.js'; +import { SearchToolCall } from './SearchToolCall.js'; +import { ThinkToolCall } from './ThinkToolCall.js'; +import { WebFetchToolCall } from './WebFetchToolCall.js'; +import type { ToolCallData } from './shared/index.js'; + +function tc(kind: string, extra?: Partial): ToolCallData { + return { kind, ...extra } as ToolCallData; +} + +function agentTc(): ToolCallData { + return { + kind: 'other', + rawOutput: { + type: 'task_execution', + taskDescription: 'test task', + status: 'completed', + }, + } as ToolCallData; +} + +describe('getToolCallComponent', () => { + it('routes agent execution to AgentToolCall', () => { + expect(getToolCallComponent(agentTc())).toBe(AgentToolCall); + }); + + it('routes read-family kinds to ReadToolCall', () => { + expect(getToolCallComponent(tc('read'))).toBe(ReadToolCall); + expect(getToolCallComponent(tc('read_file'))).toBe(ReadToolCall); + expect(getToolCallComponent(tc('read_many_files'))).toBe(ReadToolCall); + expect(getToolCallComponent(tc('readmanyfiles'))).toBe(ReadToolCall); + expect(getToolCallComponent(tc('list_directory'))).toBe(ReadToolCall); + expect(getToolCallComponent(tc('listfiles'))).toBe(ReadToolCall); + }); + + it('routes write to WriteToolCall', () => { + expect(getToolCallComponent(tc('write'))).toBe(WriteToolCall); + }); + + it('routes edit to EditToolCall', () => { + expect(getToolCallComponent(tc('edit'))).toBe(EditToolCall); + }); + + it('routes shell-family kinds to ShellToolCall', () => { + expect(getToolCallComponent(tc('execute'))).toBe(ShellToolCall); + expect(getToolCallComponent(tc('bash'))).toBe(ShellToolCall); + expect(getToolCallComponent(tc('command'))).toBe(ShellToolCall); + }); + + it('routes plan/todo kinds to UpdatedPlanToolCall', () => { + expect(getToolCallComponent(tc('updated_plan'))).toBe(UpdatedPlanToolCall); + expect(getToolCallComponent(tc('updatedplan'))).toBe(UpdatedPlanToolCall); + expect(getToolCallComponent(tc('todo_write'))).toBe(UpdatedPlanToolCall); + expect(getToolCallComponent(tc('update_todos'))).toBe(UpdatedPlanToolCall); + expect(getToolCallComponent(tc('todowrite'))).toBe(UpdatedPlanToolCall); + }); + + it('routes search-family kinds to SearchToolCall', () => { + expect(getToolCallComponent(tc('search'))).toBe(SearchToolCall); + expect(getToolCallComponent(tc('grep'))).toBe(SearchToolCall); + expect(getToolCallComponent(tc('glob'))).toBe(SearchToolCall); + expect(getToolCallComponent(tc('find'))).toBe(SearchToolCall); + }); + + it('routes think-family kinds to ThinkToolCall', () => { + expect(getToolCallComponent(tc('think'))).toBe(ThinkToolCall); + expect(getToolCallComponent(tc('thinking'))).toBe(ThinkToolCall); + }); + + it('routes fetch/web-fetch/web-search kinds to WebFetchToolCall', () => { + expect(getToolCallComponent(tc('fetch'))).toBe(WebFetchToolCall); + expect(getToolCallComponent(tc('web_fetch'))).toBe(WebFetchToolCall); + expect(getToolCallComponent(tc('webfetch'))).toBe(WebFetchToolCall); + expect(getToolCallComponent(tc('web_search'))).toBe(WebFetchToolCall); + }); + + it('falls back to GenericToolCall for unknown kinds', () => { + expect(getToolCallComponent(tc('unknown_tool'))).toBe(GenericToolCall); + expect(getToolCallComponent(tc('mcp_tool'))).toBe(GenericToolCall); + }); + + it('performs case-insensitive kind matching', () => { + expect(getToolCallComponent(tc('Read'))).toBe(ReadToolCall); + expect(getToolCallComponent(tc('BASH'))).toBe(ShellToolCall); + expect(getToolCallComponent(tc('Web_Search'))).toBe(WebFetchToolCall); + }); +}); diff --git a/packages/webui/src/components/toolcalls/routing.ts b/packages/webui/src/components/toolcalls/routing.ts new file mode 100644 index 00000000000..f1587267123 --- /dev/null +++ b/packages/webui/src/components/toolcalls/routing.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * Shared tool-call routing — maps ToolCallData to the appropriate + * specialized component. Used by both ChatViewer and VSCode IDE. + */ + +import type { FC } from 'react'; +import type { BaseToolCallProps, ToolCallData } from './shared/index.js'; +import { AgentToolCall, isAgentExecutionToolCall } from './AgentToolCall.js'; +import { GenericToolCall } from './GenericToolCall.js'; +import { ThinkToolCall } from './ThinkToolCall.js'; +import { EditToolCall } from './EditToolCall.js'; +import { WriteToolCall } from './WriteToolCall.js'; +import { SearchToolCall } from './SearchToolCall.js'; +import { UpdatedPlanToolCall } from './UpdatedPlanToolCall.js'; +import { ShellToolCall } from './ShellToolCall.js'; +import { ReadToolCall } from './ReadToolCall.js'; +import { WebFetchToolCall } from './WebFetchToolCall.js'; + +/** + * Returns the appropriate tool-call component for the given tool call data. + * + * Checks for structured agent execution output first, then falls back to + * kind-based routing. + */ +export function getToolCallComponent( + toolCall: ToolCallData, +): FC { + if (isAgentExecutionToolCall(toolCall)) { + return AgentToolCall; + } + + const normalizedKind = toolCall.kind.toLowerCase(); + + switch (normalizedKind) { + case 'read': + case 'read_file': + case 'read_many_files': + case 'readmanyfiles': + case 'list_directory': + case 'listfiles': + return ReadToolCall; + case 'write': + return WriteToolCall; + case 'edit': + return EditToolCall; + case 'execute': + case 'bash': + case 'command': + return ShellToolCall; + case 'updated_plan': + case 'updatedplan': + case 'todo_write': + case 'update_todos': + case 'todowrite': + return UpdatedPlanToolCall; + case 'search': + case 'grep': + case 'glob': + case 'find': + return SearchToolCall; + case 'think': + case 'thinking': + return ThinkToolCall; + case 'fetch': + case 'web_fetch': + case 'webfetch': + case 'web_search': // compatibility alias for legacy persisted tool-call records + return WebFetchToolCall; + default: + return GenericToolCall; + } +} diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 56bbc89aa2e..567d5970729 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -165,6 +165,7 @@ export { ReadToolCall, WebFetchToolCall, CheckboxDisplay, + getToolCallComponent, } from './components/toolcalls'; export type { ToolCallContainerProps,