diff --git a/extensions/cli/package-lock.json b/extensions/cli/package-lock.json index b86896f4f0..34d2ce3514 100644 --- a/extensions/cli/package-lock.json +++ b/extensions/cli/package-lock.json @@ -120,9 +120,9 @@ "license": "Apache-2.0", "dependencies": { "@anthropic-ai/sdk": "^0.62.0", - "@aws-sdk/client-bedrock-runtime": "^3.779.0", + "@aws-sdk/client-bedrock-runtime": "^3.931.0", "@aws-sdk/client-sagemaker-runtime": "^3.777.0", - "@aws-sdk/credential-providers": "^3.778.0", + "@aws-sdk/credential-providers": "^3.931.0", "@continuedev/config-types": "^1.0.13", "@continuedev/config-yaml": "file:../packages/config-yaml", "@continuedev/fetch": "file:../packages/fetch", @@ -275,8 +275,8 @@ "@ai-sdk/anthropic": "^1.0.10", "@ai-sdk/openai": "^1.0.10", "@anthropic-ai/sdk": "^0.67.0", - "@aws-sdk/client-bedrock-runtime": "^3.929.0", - "@aws-sdk/credential-providers": "^3.929.0", + "@aws-sdk/client-bedrock-runtime": "^3.931.0", + "@aws-sdk/credential-providers": "^3.931.0", "@continuedev/config-types": "^1.0.14", "@continuedev/config-yaml": "^1.36.0", "@continuedev/fetch": "^1.6.0", @@ -520,7 +520,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -544,7 +543,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1809,7 +1807,6 @@ "integrity": "sha512-oNXsh2ywth5aowwIa7RKtawnkdH6LgU1ztfP9AIUCQCvzysB+WeU8o2kyyosDPwBZutPpjZDKPQGIzzrfTWweQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.1", @@ -1980,7 +1977,6 @@ "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" } @@ -2002,7 +1998,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.0.1.tgz", "integrity": "sha512-XuY23lSI3d4PEqKA+7SLtAgwqIfc6E/E9eAQWLN1vlpC53ybO3o6jW4BsXo1xvz9lYyyWItfQDDLzezER01mCw==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -2015,7 +2010,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2257,7 +2251,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.203.0.tgz", "integrity": "sha512-ke1qyM+3AK2zPuBPb6Hk/GCsc5ewbLvPNkEuELx/JmANeEp6ZjnZ+wypPAJSucTw0wvCGrUaibDSdcrGFoWxKQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.203.0", "import-in-the-middle": "^1.8.1", @@ -3574,7 +3567,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -3663,7 +3655,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -3699,7 +3690,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.36.0.tgz", "integrity": "sha512-TtxJSRD8Ohxp6bKkhrm27JRHAxPczQA7idtcTOMYI+wQRRrfgqxHv1cFbCApcSnNjtXkmzFozn6jQtFrOmbjPQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -5110,7 +5100,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -5398,7 +5389,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -5468,7 +5458,6 @@ "integrity": "sha512-lr3jdBw/BGj49Eps7EvqlUaoeA0xpj3pc0RoJkHpYaCHkVK7i28dKyImLQb3JVlqs3aYSXf7qYuWOW/fgZnTXQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -5590,7 +5579,6 @@ "integrity": "sha512-w/EboPlBwnmOBtRbiOvzjD+wdiZdgFeo17lkltrtn7X37vagKKWJABvyfsJXTlHe6XBzugmYgd4A4nW+k8Mixw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.40.0", @@ -5621,7 +5609,6 @@ "integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.40.0", "@typescript-eslint/types": "8.40.0", @@ -6222,7 +6209,6 @@ "integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/utils": "3.2.4", "fflate": "^0.8.2", @@ -6303,7 +6289,6 @@ "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" }, @@ -6511,6 +6496,7 @@ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "dequal": "^2.0.3" } @@ -8384,7 +8370,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dot-prop": { "version": "5.3.0", @@ -8925,7 +8912,6 @@ "integrity": "sha512-TS9bTNIryDzStCpJN93aC5VRSW3uTx9sClUn4B87pwiCaJh220otoI0X8mJKr+VcPtniMdN8GKjlwgWGUv5ZKA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -9097,7 +9083,6 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -9545,7 +9530,6 @@ "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -12142,6 +12126,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -12169,7 +12154,6 @@ "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", "dev": true, "license": "MIT", - "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -15077,7 +15061,6 @@ "dev": true, "inBundle": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -15992,7 +15975,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -16214,7 +16196,6 @@ "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -16318,6 +16299,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -16333,6 +16315,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -16561,7 +16544,6 @@ "integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -16572,6 +16554,7 @@ "integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -16584,14 +16567,16 @@ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-reconciler": { "version": "0.32.0", @@ -17083,7 +17068,6 @@ "integrity": "sha512-g7RssbTAbir1k/S7uSwSVZFfFXwpomUB9Oas0+xi9KStSCmeDXcA7rNhiskjLqvUe/Evhx8fVCT16OSa34eM5g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@semantic-release/commit-analyzer": "^13.0.0-beta.1", "@semantic-release/error": "^4.0.0", @@ -18616,7 +18600,6 @@ "integrity": "sha512-yyxBKfORQ7LuRt/BQKBXrpcq59ZvSW0XxwfjAt3w2/8PmdxaFzijtMhTawprSHhpzeM5BgU2hXHG3lklIERZXg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -18766,7 +18749,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -18886,7 +18868,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -19000,7 +18981,6 @@ "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -19099,7 +19079,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -19635,7 +19614,6 @@ "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -19710,7 +19688,6 @@ "integrity": "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==", "dev": true, "license": "ISC", - "peer": true, "bin": { "yaml": "bin.mjs" }, @@ -19840,7 +19817,6 @@ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/extensions/cli/src/util/exit.test.ts b/extensions/cli/src/util/exit.test.ts index a45e9b4fc3..fdf9191aed 100644 --- a/extensions/cli/src/util/exit.test.ts +++ b/extensions/cli/src/util/exit.test.ts @@ -624,4 +624,624 @@ describe("updateAgentMetadata", () => { expect(metadata.extractSummary).toHaveBeenCalledWith(undefined); }); }); + + describe("concurrency and race conditions", () => { + it("should handle multiple simultaneous calls", async () => { + const history = [createMockChatHistoryItem("Test", "assistant")]; + vi.spyOn(metadata, "extractSummary").mockReturnValue("Test"); + + // Simulate multiple concurrent updates + const promises = [ + updateAgentMetadata({ history }), + updateAgentMetadata({ history }), + updateAgentMetadata({ history }), + ]; + + await expect(Promise.all(promises)).resolves.not.toThrow(); + expect(metadata.postAgentMetadata).toHaveBeenCalledTimes(3); + }); + + it("should handle interleaved isComplete true/false calls", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "changes", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 5, + deletions: 2, + }); + + await updateAgentMetadata({ isComplete: false }); + await updateAgentMetadata({ isComplete: true }); + await updateAgentMetadata({ isComplete: false }); + + const calls = (metadata.postAgentMetadata as any).mock.calls; + expect(calls[0][1]).not.toHaveProperty("isComplete"); + expect(calls[1][1]).toHaveProperty("isComplete", true); + expect(calls[2][1]).not.toHaveProperty("isComplete"); + }); + + it("should handle slow git diff with fast metadata posting", async () => { + let resolveGitDiff: (value: any) => void; + const gitDiffPromise = new Promise((resolve) => { + resolveGitDiff = resolve; + }); + + vi.spyOn(git, "getGitDiffSnapshot").mockReturnValue( + gitDiffPromise as any, + ); + vi.spyOn(metadata, "extractSummary").mockReturnValue("Fast summary"); + + const history = [createMockChatHistoryItem("Test", "assistant")]; + const updatePromise = updateAgentMetadata({ history }); + + // Resolve git diff after a delay + setTimeout(() => { + resolveGitDiff!({ diff: "delayed diff", repoFound: true }); + }, 10); + + await updatePromise; + expect(metadata.postAgentMetadata).toHaveBeenCalled(); + }); + }); + + describe("boundary value testing", () => { + it("should handle very large history arrays", async () => { + const largeHistory = Array.from({ length: 10000 }, (_, i) => + createMockChatHistoryItem(`Message ${i}`, "assistant"), + ); + vi.spyOn(metadata, "extractSummary").mockReturnValue("Summary"); + + await expect( + updateAgentMetadata({ history: largeHistory }), + ).resolves.not.toThrow(); + expect(metadata.extractSummary).toHaveBeenCalledWith(largeHistory); + }); + + it("should handle very large diff stats", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "large diff", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 999999, + deletions: 888888, + }); + + await updateAgentMetadata({ isComplete: true }); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + additions: 999999, + deletions: 888888, + hasChanges: true, + }), + ); + }); + + it("should handle very small token costs", async () => { + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockReturnValue({ + totalCost: 0.000001, + promptTokens: 1, + completionTokens: 1, + promptTokensDetails: {}, + }); + + await updateAgentMetadata({}); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + usage: expect.objectContaining({ + totalCost: 0.000001, + }), + }), + ); + }); + + it("should handle maximum precision cost rounding", async () => { + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockReturnValue({ + totalCost: 0.123456789012345, + promptTokens: 1000, + completionTokens: 500, + promptTokensDetails: {}, + }); + + await updateAgentMetadata({}); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + usage: expect.objectContaining({ + totalCost: 0.123457, // Rounded to 6 decimals + }), + }), + ); + }); + + it("should handle zero values in all fields", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 0, + deletions: 0, + }); + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockReturnValue({ + totalCost: 0, + promptTokens: 0, + completionTokens: 0, + promptTokensDetails: {}, + }); + + await updateAgentMetadata({ history: [] }); + + expect(metadata.postAgentMetadata).not.toHaveBeenCalled(); + }); + }); + + describe("complex state transitions", () => { + it("should handle transition from no data to complete with data", async () => { + // First call: no data + await updateAgentMetadata({}); + expect(metadata.postAgentMetadata).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Second call: complete with data + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "new changes", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 10, + deletions: 5, + }); + + await updateAgentMetadata({ isComplete: true }); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + isComplete: true, + hasChanges: true, + }), + ); + }); + + it("should handle summary changing between calls", async () => { + const history1 = [createMockChatHistoryItem("First", "assistant")]; + const history2 = [createMockChatHistoryItem("Second", "assistant")]; + + vi.spyOn(metadata, "extractSummary") + .mockReturnValueOnce("First") + .mockReturnValueOnce("Second"); + + await updateAgentMetadata({ history: history1 }); + await updateAgentMetadata({ history: history2 }); + + const calls = (metadata.postAgentMetadata as any).mock.calls; + expect(calls[0][1].summary).toBe("First"); + expect(calls[1][1].summary).toBe("Second"); + }); + + it("should handle diff stats changing between calls", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "changes", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats") + .mockReturnValueOnce({ additions: 5, deletions: 2 }) + .mockReturnValueOnce({ additions: 15, deletions: 8 }); + + await updateAgentMetadata({}); + await updateAgentMetadata({}); + + const calls = (metadata.postAgentMetadata as any).mock.calls; + expect(calls[0][1]).toMatchObject({ additions: 5, deletions: 2 }); + expect(calls[1][1]).toMatchObject({ additions: 15, deletions: 8 }); + }); + }); + + describe("error recovery scenarios", () => { + it("should recover from git diff timeout", async () => { + vi.spyOn(git, "getGitDiffSnapshot") + .mockRejectedValueOnce(new Error("Timeout")) + .mockResolvedValueOnce({ diff: "success", repoFound: true }); + + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 5, + deletions: 2, + }); + + // First call fails + await updateAgentMetadata({}); + expect(metadata.postAgentMetadata).not.toHaveBeenCalled(); + + vi.clearAllMocks(); + + // Second call succeeds + await updateAgentMetadata({}); + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ additions: 5 }), + ); + }); + + it("should handle all collectors failing", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockRejectedValue( + new Error("Git error"), + ); + vi.spyOn(metadata, "extractSummary").mockImplementation(() => { + throw new Error("Summary error"); + }); + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockImplementation(() => { + throw new Error("Session error"); + }); + + await expect(updateAgentMetadata({})).resolves.not.toThrow(); + expect(metadata.postAgentMetadata).not.toHaveBeenCalled(); + }); + + it("should handle partial collector failures", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockRejectedValue( + new Error("Git error"), + ); + vi.spyOn(metadata, "extractSummary").mockReturnValue("Working summary"); + + const history = [createMockChatHistoryItem("Test", "assistant")]; + await updateAgentMetadata({ history }); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + summary: "Working summary", + }), + ); + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.not.objectContaining({ + additions: expect.anything(), + }), + ); + }); + }); + + describe("cache token details handling", () => { + it("should include only cachedTokens when cacheWriteTokens is 0", async () => { + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockReturnValue({ + totalCost: 0.01, + promptTokens: 100, + completionTokens: 50, + promptTokensDetails: { + cachedTokens: 50, + cacheWriteTokens: 0, + }, + }); + + await updateAgentMetadata({}); + + const callArgs = (metadata.postAgentMetadata as any).mock.calls[0][1]; + expect(callArgs.usage.cachedTokens).toBe(50); + expect(callArgs.usage).not.toHaveProperty("cacheWriteTokens"); + }); + + it("should include only cacheWriteTokens when cachedTokens is 0", async () => { + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockReturnValue({ + totalCost: 0.01, + promptTokens: 100, + completionTokens: 50, + promptTokensDetails: { + cachedTokens: 0, + cacheWriteTokens: 25, + }, + }); + + await updateAgentMetadata({}); + + const callArgs = (metadata.postAgentMetadata as any).mock.calls[0][1]; + expect(callArgs.usage).not.toHaveProperty("cachedTokens"); + expect(callArgs.usage.cacheWriteTokens).toBe(25); + }); + + it("should handle undefined promptTokensDetails", async () => { + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockReturnValue({ + totalCost: 0.01, + promptTokens: 100, + completionTokens: 50, + promptTokensDetails: undefined as any, + }); + + await updateAgentMetadata({}); + + const callArgs = (metadata.postAgentMetadata as any).mock.calls[0][1]; + expect(callArgs.usage).not.toHaveProperty("cachedTokens"); + expect(callArgs.usage).not.toHaveProperty("cacheWriteTokens"); + }); + }); + + describe("metadata field validation", () => { + it("should only include expected fields in metadata", async () => { + const history = [createMockChatHistoryItem("Test", "assistant")]; + vi.spyOn(metadata, "extractSummary").mockReturnValue("Test"); + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "changes", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 5, + deletions: 2, + }); + + await updateAgentMetadata({ history, isComplete: true }); + + const callArgs = (metadata.postAgentMetadata as any).mock.calls[0][1]; + const expectedFields = [ + "additions", + "deletions", + "isComplete", + "hasChanges", + "summary", + ]; + const actualFields = Object.keys(callArgs); + + // All expected fields should be present + expectedFields.forEach((field) => { + expect(actualFields).toContain(field); + }); + + // No unexpected fields + actualFields.forEach((field) => { + expect([...expectedFields, "usage"]).toContain(field); + }); + }); + + it("should not include falsy values except 0", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 0, + deletions: 0, + }); + + await updateAgentMetadata({ isComplete: true }); + + const callArgs = (metadata.postAgentMetadata as any).mock.calls[0][1]; + // Should include isComplete and hasChanges even though hasChanges is false + expect(callArgs.isComplete).toBe(true); + expect(callArgs.hasChanges).toBe(false); + // Should not include additions/deletions when they are 0 + expect(callArgs).not.toHaveProperty("additions"); + expect(callArgs).not.toHaveProperty("deletions"); + }); + }); + + describe("sequential update scenarios", () => { + it("should handle rapid sequential updates correctly", async () => { + const history1 = [createMockChatHistoryItem("First", "assistant")]; + const history2 = [createMockChatHistoryItem("Second", "assistant")]; + const history3 = [createMockChatHistoryItem("Third", "assistant")]; + + vi.spyOn(metadata, "extractSummary") + .mockReturnValueOnce("First") + .mockReturnValueOnce("Second") + .mockReturnValueOnce("Third"); + + await updateAgentMetadata({ history: history1 }); + await updateAgentMetadata({ history: history2 }); + await updateAgentMetadata({ history: history3 }); + + expect(metadata.postAgentMetadata).toHaveBeenCalledTimes(3); + const calls = (metadata.postAgentMetadata as any).mock.calls; + expect(calls[0][1].summary).toBe("First"); + expect(calls[1][1].summary).toBe("Second"); + expect(calls[2][1].summary).toBe("Third"); + }); + + it("should handle updates with incrementing usage data", async () => { + const { getSessionUsage } = await import("../session.js"); + + vi.mocked(getSessionUsage) + .mockReturnValueOnce({ + totalCost: 0.01, + promptTokens: 100, + completionTokens: 50, + promptTokensDetails: {}, + }) + .mockReturnValueOnce({ + totalCost: 0.025, + promptTokens: 250, + completionTokens: 125, + promptTokensDetails: {}, + }) + .mockReturnValueOnce({ + totalCost: 0.05, + promptTokens: 500, + completionTokens: 250, + promptTokensDetails: {}, + }); + + await updateAgentMetadata({}); + await updateAgentMetadata({}); + await updateAgentMetadata({}); + + const calls = (metadata.postAgentMetadata as any).mock.calls; + expect(calls[0][1].usage.totalCost).toBe(0.01); + expect(calls[1][1].usage.totalCost).toBe(0.025); + expect(calls[2][1].usage.totalCost).toBe(0.05); + }); + }); + + describe("collector independence", () => { + it("should collect diff stats even if summary extraction fails", async () => { + vi.spyOn(metadata, "extractSummary").mockImplementation(() => { + throw new Error("Summary error"); + }); + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "changes", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 10, + deletions: 5, + }); + + const history = [createMockChatHistoryItem("Test", "assistant")]; + await updateAgentMetadata({ history }); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + additions: 10, + deletions: 5, + }), + ); + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.not.objectContaining({ + summary: expect.anything(), + }), + ); + }); + + it("should collect summary even if usage collection fails", async () => { + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockImplementation(() => { + throw new Error("Usage error"); + }); + vi.spyOn(metadata, "extractSummary").mockReturnValue("Good summary"); + + const history = [createMockChatHistoryItem("Test", "assistant")]; + await updateAgentMetadata({ history }); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + summary: "Good summary", + }), + ); + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.not.objectContaining({ + usage: expect.anything(), + }), + ); + }); + + it("should collect usage even if git diff fails", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockRejectedValue( + new Error("Git error"), + ); + const { getSessionUsage } = await import("../session.js"); + vi.mocked(getSessionUsage).mockReturnValue({ + totalCost: 0.01, + promptTokens: 100, + completionTokens: 50, + promptTokensDetails: {}, + }); + + await updateAgentMetadata({}); + + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + usage: expect.any(Object), + }), + ); + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.not.objectContaining({ + additions: expect.anything(), + }), + ); + }); + }); + + describe("metadata consistency", () => { + it("should maintain consistency between hasChanges and diff stats", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "changes", + repoFound: true, + }); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 15, + deletions: 8, + }); + + await updateAgentMetadata({ isComplete: true }); + + const callArgs = (metadata.postAgentMetadata as any).mock.calls[0][1]; + expect(callArgs.hasChanges).toBe(true); + expect(callArgs.additions).toBe(15); + expect(callArgs.deletions).toBe(8); + }); + + it("should set hasChanges=false when no diff stats", async () => { + vi.spyOn(git, "getGitDiffSnapshot").mockResolvedValue({ + diff: "", + repoFound: true, + }); + + await updateAgentMetadata({ isComplete: true }); + + const callArgs = (metadata.postAgentMetadata as any).mock.calls[0][1]; + expect(callArgs.hasChanges).toBe(false); + expect(callArgs).not.toHaveProperty("additions"); + expect(callArgs).not.toHaveProperty("deletions"); + }); + }); + + describe("async behavior", () => { + it("should wait for all async collectors before posting", async () => { + let gitResolved = false; + let gitDiffResolver: (value: any) => void; + + const gitDiffPromise = new Promise((resolve) => { + gitDiffResolver = (value) => { + gitResolved = true; + resolve(value); + }; + }); + + vi.spyOn(git, "getGitDiffSnapshot").mockReturnValue( + gitDiffPromise as any, + ); + vi.spyOn(metadata, "extractSummary").mockReturnValue("Summary"); + vi.spyOn(metadata, "calculateDiffStats").mockReturnValue({ + additions: 5, + deletions: 2, + }); + + const history = [createMockChatHistoryItem("Test", "assistant")]; + const updatePromise = updateAgentMetadata({ history }); + + // Verify postAgentMetadata hasn't been called yet + expect(metadata.postAgentMetadata).not.toHaveBeenCalled(); + + // Resolve git diff + gitDiffResolver!({ diff: "changes", repoFound: true }); + await updatePromise; + + expect(gitResolved).toBe(true); + expect(metadata.postAgentMetadata).toHaveBeenCalledWith( + mockAgentId, + expect.objectContaining({ + additions: 5, + deletions: 2, + summary: "Summary", + }), + ); + }); + }); }); diff --git a/extensions/cli/src/util/metadata.test.ts b/extensions/cli/src/util/metadata.test.ts index 668516d56d..de6265e703 100644 --- a/extensions/cli/src/util/metadata.test.ts +++ b/extensions/cli/src/util/metadata.test.ts @@ -1,381 +1,851 @@ import type { ChatHistoryItem } from "core/index.js"; -import { describe, expect, it } from "vitest"; - -import { calculateDiffStats, extractSummary } from "./metadata.js"; - -// Helper to create a mock chat history item -function createMockChatHistoryItem( - content: string, - role: "user" | "assistant" | "system" = "assistant", -): ChatHistoryItem { - return { - message: { - role, - content, - }, - } as ChatHistoryItem; -} - -describe("metadata utilities", () => { - describe("calculateDiffStats", () => { - it("should count additions and deletions in a simple diff", () => { - const diff = `diff --git a/file.txt b/file.txt -index 123..456 789 ---- a/file.txt -+++ b/file.txt -@@ -1,3 +1,3 @@ - line 1 --line 2 -+line 2 modified - line 3`; - - const stats = calculateDiffStats(diff); - - expect(stats.additions).toBe(1); - expect(stats.deletions).toBe(1); +import { beforeEach, afterEach, describe, expect, it } from "vitest"; + +import { + calculateDiffStats, + extractSummary, + getAgentIdFromArgs, +} from "./metadata.js"; + +describe("calculateDiffStats", () => { + describe("basic functionality", () => { + it("should return zero stats for empty diff", () => { + expect(calculateDiffStats("")).toEqual({ additions: 0, deletions: 0 }); }); - it("should handle multiple hunks with multiple changes", () => { - const diff = `diff --git a/app.js b/app.js ---- a/app.js -+++ b/app.js -@@ -10,5 +10,7 @@ - const express = require('express'); --const old = 'value'; -+const new = 'value'; -+const another = 'line'; - -@@ -50,3 +52,2 @@ --function oldFunc() {} --function anotherOld() {} -+function newFunc() {}`; - - const stats = calculateDiffStats(diff); - - expect(stats.additions).toBe(3); // +const new, +const another, +function newFunc - expect(stats.deletions).toBe(3); // -const old, -function oldFunc, -function anotherOld + it("should return zero stats for whitespace-only diff", () => { + expect(calculateDiffStats(" \n\n\t ")).toEqual({ + additions: 0, + deletions: 0, + }); }); - it("should exclude diff metadata lines from counts", () => { - const diff = `diff --git a/file.txt b/file.txt + it("should count simple additions", () => { + const diff = ` +diff --git a/file.ts b/file.ts index abc123..def456 100644 ---- a/file.txt -+++ b/file.txt -@@ -1 +1 @@ --old content -+new content`; - - const stats = calculateDiffStats(diff); - - // Should only count the actual changes, not the metadata lines - expect(stats.additions).toBe(1); - expect(stats.deletions).toBe(1); +--- a/file.ts ++++ b/file.ts +@@ -1,3 +1,4 @@ + const x = 1; ++const y = 2; + const z = 3; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); }); - it("should handle binary file changes", () => { - const diff = `diff --git a/image.png b/image.png -Binary files a/image.png and b/image.png differ`; - - const stats = calculateDiffStats(diff); - - // Binary files shouldn't be counted - expect(stats.additions).toBe(0); - expect(stats.deletions).toBe(0); + it("should count simple deletions", () => { + const diff = ` +diff --git a/file.ts b/file.ts +--- a/file.ts ++++ b/file.ts +@@ -1,3 +1,2 @@ + const x = 1; +-const y = 2; + const z = 3; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 0, deletions: 1 }); }); - it("should return zeros for empty diff", () => { - const stats = calculateDiffStats(""); - - expect(stats.additions).toBe(0); - expect(stats.deletions).toBe(0); + it("should count both additions and deletions", () => { + const diff = ` +diff --git a/file.ts b/file.ts +--- a/file.ts ++++ b/file.ts +@@ -1,3 +1,3 @@ +-const x = 1; ++const x = 2; + const y = 2; ++const z = 3; +-const old = 1; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 2, deletions: 2 }); }); + }); - it("should return zeros for whitespace-only diff", () => { - const stats = calculateDiffStats(" \n\n \t "); + describe("metadata line filtering", () => { + it("should ignore file header lines with spaces", () => { + const diff = ` +--- a/file.ts ++++ b/file.ts ++actual addition +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); + }); - expect(stats.additions).toBe(0); - expect(stats.deletions).toBe(0); + it("should ignore hunk headers", () => { + const diff = ` +@@ -1,3 +1,4 @@ ++addition +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); }); - it("should handle a diff with only additions", () => { - const diff = `diff --git a/new.txt b/new.txt ---- /dev/null -+++ b/new.txt -@@ -0,0 +1,3 @@ -+line 1 -+line 2 -+line 3`; + it("should ignore diff metadata lines", () => { + const diff = ` +diff --git a/file.ts b/file.ts +index abc123..def456 100644 ++addition +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); + }); - const stats = calculateDiffStats(diff); + it("should ignore binary file markers", () => { + const diff = ` +Binary files a/image.png and b/image.png differ ++text addition +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); + }); - expect(stats.additions).toBe(3); - expect(stats.deletions).toBe(0); + it("should count lines starting with +++ without space as code", () => { + const diff = ` ++++ b/file.ts ++++counter; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); }); - it("should handle a diff with only deletions", () => { - const diff = `diff --git a/old.txt b/old.txt ---- a/old.txt -+++ /dev/null -@@ -1,3 +0,0 @@ --line 1 --line 2 --line 3`; + it("should count lines starting with --- without space as code", () => { + const diff = ` +--- a/file.ts +---counter; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 0, deletions: 1 }); + }); + }); - const stats = calculateDiffStats(diff); + describe("edge cases", () => { + it("should handle multiple files in one diff", () => { + const diff = ` +diff --git a/file1.ts b/file1.ts +--- a/file1.ts ++++ b/file1.ts ++addition in file1 +-deletion in file1 +diff --git a/file2.ts b/file2.ts +--- a/file2.ts ++++ b/file2.ts ++addition in file2 ++another addition in file2 +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 3, deletions: 1 }); + }); - expect(stats.additions).toBe(0); - expect(stats.deletions).toBe(3); + it("should handle very large diffs", () => { + const additions = Array.from({ length: 10000 }, () => "+added line").join( + "\n", + ); + const deletions = Array.from( + { length: 5000 }, + () => "-deleted line", + ).join("\n"); + const diff = `${additions}\n${deletions}`; + + expect(calculateDiffStats(diff)).toEqual({ + additions: 10000, + deletions: 5000, + }); }); - it("should handle real-world TypeScript diff", () => { - const diff = `diff --git a/src/util/metadata.ts b/src/util/metadata.ts + it("should handle diff with only metadata", () => { + const diff = ` +diff --git a/file.ts b/file.ts index abc123..def456 100644 ---- a/src/util/metadata.ts -+++ b/src/util/metadata.ts -@@ -1,10 +1,15 @@ - import type { ChatHistoryItem } from "core/index.js"; - --export function oldFunction() { -- return "old"; -+export function newFunction() { -+ return "new"; -+} -+ -+export function anotherFunction() { -+ return "another"; - } - - // Comment line (unchanged) --const OLD_CONSTANT = 42; -+const NEW_CONSTANT = 43;`; - - const stats = calculateDiffStats(diff); +--- a/file.ts ++++ b/file.ts +@@ -1,3 +1,3 @@ +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 0, deletions: 0 }); + }); - expect(stats.additions).toBe(7); - expect(stats.deletions).toBe(3); + it("should handle mixed line endings", () => { + const diff = "+line1\r\n-line2\r+line3\n-line4"; + expect(calculateDiffStats(diff)).toEqual({ additions: 2, deletions: 2 }); }); + }); - it("should correctly count code with ++ or -- operators", () => { - // This tests the edge case where code contains ++ or -- at the start - const diff = `diff --git a/counter.js b/counter.js + describe("real-world scenarios", () => { + it("should handle refactoring with many changes", () => { + const diff = ` +diff --git a/src/component.tsx b/src/component.tsx index abc123..def456 100644 ---- a/counter.js -+++ b/counter.js -@@ -1,5 +1,5 @@ - function increment(counter) { -- counter++; -+ ++counter; +--- a/src/component.tsx ++++ b/src/component.tsx +@@ -10,15 +10,20 @@ import React from "react"; +-export function OldComponent() { ++export function NewComponent() { + const [state, setState] = useState(0); +- const oldLogic = () => { +- console.log("old"); +- }; ++ const newLogic = () => { ++ console.log("new"); ++ console.log("improved"); ++ }; + return ( +-