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 ( +-
++
++ Extra element + {state} +
+ ); } +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 8, deletions: 5 }); + }); - function decrement(counter) { -- counter--; -+ --counter; - }`; - - const stats = calculateDiffStats(diff); - - // Should count all 4 changes: 2 additions and 2 deletions - // Lines like "+++counter;" should be counted as additions, not skipped as file headers - expect(stats.additions).toBe(2); - expect(stats.deletions).toBe(2); + it("should handle adding a new file", () => { + const diff = ` +diff --git a/new-file.ts b/new-file.ts +new file mode 100644 +index 0000000..abc123 +--- /dev/null ++++ b/new-file.ts +@@ -0,0 +1,5 @@ ++export const newFunction = () => { ++ console.log("new"); ++ return true; ++}; ++ +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 5, deletions: 0 }); }); - it("should handle code with multiple + or - at line start", () => { - const diff = `diff --git a/operators.c b/operators.c ---- a/operators.c -+++ b/operators.c -@@ -1,4 +1,4 @@ - int main() { -- x--; -- y++; -+ --x; -+ ++y; - return 0; - }`; + it("should handle deleting a file", () => { + const diff = ` +diff --git a/old-file.ts b/old-file.ts +deleted file mode 100644 +index abc123..0000000 +--- a/old-file.ts ++++ /dev/null +@@ -1,3 +0,0 @@ +-export const oldFunction = () => { +- return false; +-}; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 0, deletions: 3 }); + }); + }); +}); - const stats = calculateDiffStats(diff); +describe("extractSummary", () => { + const createHistoryItem = ( + content: string, + role: "user" | "assistant" | "system" = "assistant", + ): ChatHistoryItem => + ({ + message: { role, content }, + }) as ChatHistoryItem; - expect(stats.additions).toBe(2); - expect(stats.deletions).toBe(2); + describe("basic functionality", () => { + it("should return undefined for empty history", () => { + expect(extractSummary([])).toBeUndefined(); }); - }); - describe("extractSummary", () => { it("should extract last assistant message", () => { const history = [ - createMockChatHistoryItem("Hello", "user"), - createMockChatHistoryItem("Hi there, how can I help?", "assistant"), - createMockChatHistoryItem("Please fix the bug", "user"), - createMockChatHistoryItem( - "I've fixed the authentication bug in the login module.", - "assistant", - ), + createHistoryItem("User message", "user"), + createHistoryItem("Assistant response", "assistant"), ]; + expect(extractSummary(history)).toBe("Assistant response"); + }); - const summary = extractSummary(history); + it("should find last assistant message when followed by user message", () => { + const history = [ + createHistoryItem("First assistant", "assistant"), + createHistoryItem("User question", "user"), + ]; + expect(extractSummary(history)).toBe("First assistant"); + }); - expect(summary).toBe( - "I've fixed the authentication bug in the login module.", - ); + it("should return most recent assistant message", () => { + const history = [ + createHistoryItem("Old assistant", "assistant"), + createHistoryItem("User", "user"), + createHistoryItem("Recent assistant", "assistant"), + ]; + expect(extractSummary(history)).toBe("Recent assistant"); }); it("should skip empty assistant messages", () => { const history = [ - createMockChatHistoryItem("User message", "user"), - createMockChatHistoryItem("Valid assistant message", "assistant"), - createMockChatHistoryItem("", "assistant"), - createMockChatHistoryItem(" ", "assistant"), + createHistoryItem("Good message", "assistant"), + createHistoryItem("", "assistant"), + createHistoryItem(" ", "assistant"), ]; + expect(extractSummary(history)).toBe("Good message"); + }); - const summary = extractSummary(history); + it("should trim whitespace from message", () => { + const history = [ + createHistoryItem(" \n Message with whitespace \n ", "assistant"), + ]; + expect(extractSummary(history)).toBe("Message with whitespace"); + }); + }); - expect(summary).toBe("Valid assistant message"); + describe("truncation behavior", () => { + it("should not truncate short messages", () => { + const shortMessage = "Short message"; + const history = [createHistoryItem(shortMessage, "assistant")]; + expect(extractSummary(history)).toBe(shortMessage); }); - it("should truncate long messages to default 500 characters", () => { + it("should truncate messages exceeding maxLength", () => { const longMessage = "a".repeat(600); - const history = [createMockChatHistoryItem(longMessage, "assistant")]; - - const summary = extractSummary(history); + const history = [createHistoryItem(longMessage, "assistant")]; + const result = extractSummary(history); - expect(summary?.length).toBe(500); - expect(summary).toBe("a".repeat(497) + "..."); + expect(result?.length).toBe(500); + expect(result?.endsWith("...")).toBe(true); + expect(result?.substring(0, 497)).toBe("a".repeat(497)); }); - it("should respect custom maxLength parameter", () => { - const longMessage = "b".repeat(200); - const history = [createMockChatHistoryItem(longMessage, "assistant")]; + it("should respect custom maxLength", () => { + const message = "a".repeat(200); + const history = [createHistoryItem(message, "assistant")]; + const result = extractSummary(history, 100); - const summary = extractSummary(history, 100); + expect(result?.length).toBe(100); + expect(result?.endsWith("...")).toBe(true); + }); - expect(summary?.length).toBe(100); - expect(summary).toBe("b".repeat(97) + "..."); + it("should handle exactly maxLength message", () => { + const message = "a".repeat(500); + const history = [createHistoryItem(message, "assistant")]; + expect(extractSummary(history)).toBe(message); }); - it("should not truncate messages under the limit", () => { - const shortMessage = "This is a short message."; - const history = [createMockChatHistoryItem(shortMessage, "assistant")]; + it("should handle maxLength + 1 message", () => { + const message = "a".repeat(501); + const history = [createHistoryItem(message, "assistant")]; + const result = extractSummary(history); - const summary = extractSummary(history, 500); + expect(result?.length).toBe(500); + expect(result?.endsWith("...")).toBe(true); + }); + }); - expect(summary).toBe(shortMessage); + describe("role filtering", () => { + it("should ignore user messages", () => { + const history = [ + createHistoryItem("User message", "user"), + createHistoryItem("Assistant message", "assistant"), + createHistoryItem("Another user", "user"), + ]; + expect(extractSummary(history)).toBe("Assistant message"); }); - it("should return undefined for empty history", () => { - const summary = extractSummary([]); + it("should ignore system messages", () => { + const history = [ + createHistoryItem("System message", "system"), + createHistoryItem("Assistant message", "assistant"), + ]; + expect(extractSummary(history)).toBe("Assistant message"); + }); - expect(summary).toBeUndefined(); + it("should return undefined when no assistant messages", () => { + const history = [ + createHistoryItem("User 1", "user"), + createHistoryItem("System", "system"), + createHistoryItem("User 2", "user"), + ]; + expect(extractSummary(history)).toBeUndefined(); }); - it("should return undefined when no assistant messages exist", () => { + it("should handle mixed roles correctly", () => { const history = [ - createMockChatHistoryItem("User message 1", "user"), - createMockChatHistoryItem("User message 2", "user"), - createMockChatHistoryItem("System message", "system"), + createHistoryItem("System init", "system"), + createHistoryItem("User query", "user"), + createHistoryItem("First assistant", "assistant"), + createHistoryItem("User followup", "user"), + createHistoryItem("Second assistant", "assistant"), + createHistoryItem("User final", "user"), ]; + expect(extractSummary(history)).toBe("Second assistant"); + }); + }); - const summary = extractSummary(history); + describe("content type handling", () => { + it("should handle string content", () => { + const history = [createHistoryItem("String content", "assistant")]; + expect(extractSummary(history)).toBe("String content"); + }); - expect(summary).toBeUndefined(); + it("should stringify non-string content", () => { + const objectContent = { type: "object", data: "value" }; + const history = [ + { + message: { role: "assistant", content: objectContent }, + } as ChatHistoryItem, + ]; + expect(extractSummary(history)).toBe(JSON.stringify(objectContent)); }); - it("should handle message content as object (multimodal)", () => { - const history: ChatHistoryItem[] = [ + it("should handle array content", () => { + const arrayContent = ["item1", "item2"]; + const history = [ { - message: { - role: "assistant", - content: [ - { type: "text", text: "Here's the image analysis" }, - { type: "image_url", image_url: { url: "data:..." } }, - ], - }, - } as any, + message: { role: "assistant", content: arrayContent }, + } as ChatHistoryItem, ]; + expect(extractSummary(history)).toBe(JSON.stringify(arrayContent)); + }); + }); - const summary = extractSummary(history); + describe("edge cases", () => { + it("should handle very long conversation history", () => { + const history = Array.from({ length: 10000 }, (_, i) => + createHistoryItem(`Message ${i}`, i % 2 === 0 ? "user" : "assistant"), + ); + expect(extractSummary(history)).toBe("Message 9999"); + }); - // Should stringify the object content - expect(summary).toBeDefined(); - expect(typeof summary).toBe("string"); - expect(summary).toContain("Here's the image analysis"); + it("should handle history with undefined content", () => { + const history = [ + { + message: { role: "assistant", content: undefined }, + } as any, + ]; + // Should skip undefined content + expect(extractSummary(history)).toBeUndefined(); }); - it("should trim whitespace from content", () => { + it("should handle multiple empty messages before valid one", () => { const history = [ - createMockChatHistoryItem( - " \n Message with whitespace \n ", - "assistant", - ), + createHistoryItem("Valid message", "assistant"), + createHistoryItem("", "assistant"), + createHistoryItem(" \n\t ", "assistant"), + createHistoryItem(null as any, "assistant"), ]; + expect(extractSummary(history)).toBe("Valid message"); + }); + }); +}); + +describe("getAgentIdFromArgs", () => { + let originalArgv: string[]; + + beforeEach(() => { + originalArgv = [...process.argv]; + }); + + afterEach(() => { + process.argv = originalArgv; + }); - const summary = extractSummary(history); + describe("basic functionality", () => { + it("should return undefined when --id flag is not present", () => { + process.argv = ["node", "script.js", "--other-flag", "value"]; + expect(getAgentIdFromArgs()).toBeUndefined(); + }); - expect(summary).toBe("Message with whitespace"); + it("should extract agent ID when --id flag is present", () => { + process.argv = ["node", "script.js", "--id", "agent-123"]; + expect(getAgentIdFromArgs()).toBe("agent-123"); }); - it("should find last assistant message among mixed roles", () => { - const history = [ - createMockChatHistoryItem("First assistant message", "assistant"), - createMockChatHistoryItem("User response", "user"), - createMockChatHistoryItem("Second assistant message", "assistant"), - createMockChatHistoryItem("Another user message", "user"), - createMockChatHistoryItem("System notification", "system"), + it("should return undefined when --id is last argument", () => { + process.argv = ["node", "script.js", "--id"]; + expect(getAgentIdFromArgs()).toBeUndefined(); + }); + + it("should extract ID with multiple flags", () => { + process.argv = [ + "node", + "script.js", + "--verbose", + "--id", + "agent-456", + "--debug", ]; + expect(getAgentIdFromArgs()).toBe("agent-456"); + }); + }); - const summary = extractSummary(history); + describe("edge cases", () => { + it("should handle empty argv", () => { + process.argv = []; + expect(getAgentIdFromArgs()).toBeUndefined(); + }); - expect(summary).toBe("Second assistant message"); + it("should handle --id with empty string value", () => { + process.argv = ["node", "script.js", "--id", ""]; + expect(getAgentIdFromArgs()).toBe(""); }); - it("should handle markdown formatting in messages", () => { - const history = [ - createMockChatHistoryItem( - "I've updated the code:\n\n```typescript\nfunction test() {}\n```\n\nThe changes are complete.", - "assistant", - ), - ]; + it("should handle --id with whitespace value", () => { + process.argv = ["node", "script.js", "--id", " "]; + expect(getAgentIdFromArgs()).toBe(" "); + }); - const summary = extractSummary(history); + it("should handle UUID format ID", () => { + const uuid = "550e8400-e29b-41d4-a716-446655440000"; + process.argv = ["node", "script.js", "--id", uuid]; + expect(getAgentIdFromArgs()).toBe(uuid); + }); - // Should keep markdown formatting - expect(summary).toContain("```typescript"); - expect(summary).toContain("function test()"); + it("should handle ID with special characters", () => { + const specialId = "agent-id_with.special@chars#123"; + process.argv = ["node", "script.js", "--id", specialId]; + expect(getAgentIdFromArgs()).toBe(specialId); }); - it("should handle special characters in messages", () => { - const history = [ - createMockChatHistoryItem( - 'Fixed the regex pattern: /[a-z]+/gi and added "quotes" & ', - "assistant", - ), + it("should handle multiple --id flags (returns first)", () => { + process.argv = [ + "node", + "script.js", + "--id", + "first-id", + "--id", + "second-id", ]; + expect(getAgentIdFromArgs()).toBe("first-id"); + }); - const summary = extractSummary(history); + it("should handle very long agent ID", () => { + const longId = "a".repeat(1000); + process.argv = ["node", "script.js", "--id", longId]; + expect(getAgentIdFromArgs()).toBe(longId); + }); + }); - expect(summary).toBe( - 'Fixed the regex pattern: /[a-z]+/gi and added "quotes" & ', - ); + describe("real-world scenarios", () => { + it("should extract ID from typical CLI invocation", () => { + process.argv = [ + "/usr/local/bin/node", + "/usr/local/bin/continue-cli", + "serve", + "--id", + "session-abc123", + "--prompt", + "Fix the bug", + ]; + expect(getAgentIdFromArgs()).toBe("session-abc123"); }); - it("should handle exactly 500 character message (no truncation)", () => { - const exactMessage = "x".repeat(500); - const history = [createMockChatHistoryItem(exactMessage, "assistant")]; + it("should handle ID as first argument", () => { + process.argv = ["node", "script.js", "--id", "early-id", "command"]; + expect(getAgentIdFromArgs()).toBe("early-id"); + }); - const summary = extractSummary(history, 500); + it("should handle ID as last valid argument", () => { + process.argv = ["node", "script.js", "command", "--id", "last-id"]; + expect(getAgentIdFromArgs()).toBe("last-id"); + }); + }); - expect(summary).toBe(exactMessage); - expect(summary?.length).toBe(500); - expect(summary?.endsWith("...")).toBe(false); + describe("flag variations", () => { + it("should not match --identity flag", () => { + process.argv = ["node", "script.js", "--identity", "not-an-id"]; + expect(getAgentIdFromArgs()).toBeUndefined(); }); - it("should handle 501 character message (with truncation)", () => { - const longMessage = "y".repeat(501); - const history = [createMockChatHistoryItem(longMessage, "assistant")]; + it("should not match --idempotent flag", () => { + process.argv = ["node", "script.js", "--idempotent"]; + expect(getAgentIdFromArgs()).toBeUndefined(); + }); - const summary = extractSummary(history, 500); + it("should handle --id= format (not supported)", () => { + process.argv = ["node", "script.js", "--id=test-123"]; + // Current implementation doesn't support --id=value format + expect(getAgentIdFromArgs()).toBeUndefined(); + }); - expect(summary).toBe("y".repeat(497) + "..."); - expect(summary?.length).toBe(500); + it("should handle negative numbers as ID", () => { + process.argv = ["node", "script.js", "--id", "-123"]; + expect(getAgentIdFromArgs()).toBe("-123"); }); + + it("should handle numeric string IDs", () => { + process.argv = ["node", "script.js", "--id", "12345"]; + expect(getAgentIdFromArgs()).toBe("12345"); + }); + + it("should handle IDs with paths", () => { + process.argv = ["node", "script.js", "--id", "agent/session/123"]; + expect(getAgentIdFromArgs()).toBe("agent/session/123"); + }); + + it("should handle IDs with URL format", () => { + const urlId = "https://example.com/agent/123"; + process.argv = ["node", "script.js", "--id", urlId]; + expect(getAgentIdFromArgs()).toBe(urlId); + }); + + it("should handle IDs with JSON", () => { + const jsonId = '{"type":"agent","id":123}'; + process.argv = ["node", "script.js", "--id", jsonId]; + expect(getAgentIdFromArgs()).toBe(jsonId); + }); + }); +}); + +describe("calculateDiffStats - special patterns", () => { + it("should correctly handle C++ increment/decrement operators", () => { + const diff = ` ++ counter++; +- counter--; ++ ++value; +- --value; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 2, deletions: 2 }); + }); + + it("should handle arrow operator patterns", () => { + const diff = ` ++ ptr->field; +- obj-->method(); +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 1 }); + }); + + it("should handle comment-only changes", () => { + const diff = ` ++ // Added comment +- // Removed comment ++ /* Multi-line ++ comment */ +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 3, deletions: 1 }); + }); + + it("should handle empty line changes", () => { + const diff = ` ++ +- ++ +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 2, deletions: 1 }); + }); + + it("should handle diff with only context lines", () => { + const diff = ` +@@ -1,3 +1,3 @@ + const x = 1; + const y = 2; + const z = 3; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 0, deletions: 0 }); + }); + + it("should handle conflict markers", () => { + const diff = ` ++<<<<<<< HEAD ++const x = 1; ++======= ++const x = 2; ++>>>>>>> branch +`; + // Conflict markers are counted as additions if they appear in diff + expect(calculateDiffStats(diff)).toEqual({ additions: 5, deletions: 0 }); + }); + + it("should handle permission changes without content changes", () => { + const diff = ` +diff --git a/script.sh b/script.sh +old mode 100644 +new mode 100755 +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 0, deletions: 0 }); + }); + + it("should handle symlink changes", () => { + const diff = ` +diff --git a/link b/link +new file mode 120000 +index 0000000..abc123 +--- /dev/null ++++ b/link +@@ -0,0 +1 @@ ++target/file +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); + }); + + it("should handle renamed files with no changes", () => { + const diff = ` +diff --git a/old.js b/new.js +similarity index 100% +rename from old.js +rename to new.js +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 0, deletions: 0 }); + }); + + it("should handle renamed files with changes", () => { + const diff = ` +diff --git a/old.js b/new.js +similarity index 90% +rename from old.js +rename to new.js +index abc123..def456 100644 +--- a/old.js ++++ b/new.js +@@ -1,3 +1,4 @@ + const x = 1; ++const y = 2; + const z = 3; +`; + expect(calculateDiffStats(diff)).toEqual({ additions: 1, deletions: 0 }); + }); + + it("should handle diff with many small hunks efficiently", () => { + const hunks = Array.from( + { length: 1000 }, + (_, i) => ` +@@ -${i},1 +${i},2 @@ + context line ++added line ${i} + context line +`, + ).join(""); + + const start = Date.now(); + const result = calculateDiffStats(hunks); + const duration = Date.now() - start; + + expect(result.additions).toBe(1000); + expect(result.deletions).toBe(0); + expect(duration).toBeLessThan(1000); // Should complete in under 1 second + }); + + it("should handle diff with very long lines", () => { + const longLine = "a".repeat(100000); + const diff = `+${longLine}\n-${longLine}`; + + const result = calculateDiffStats(diff); + expect(result).toEqual({ additions: 1, deletions: 1 }); + }); +}); + +describe("extractSummary - advanced content handling", () => { + const createHistoryItem = ( + content: any, + role: "user" | "assistant" | "system" = "assistant", + ): ChatHistoryItem => + ({ + message: { role, content }, + }) as ChatHistoryItem; + + it("should handle messages with markdown formatting", () => { + const markdown = ` +# Header + +**Bold text** and *italic text* + +- List item 1 +- List item 2 + +\`\`\`javascript +code block +\`\`\` +`; + const history = [createHistoryItem(markdown)]; + const result = extractSummary(history); + expect(result).toBe(markdown.trim()); + }); + + it("should handle messages with special characters", () => { + const specialChars = "Test with <>&\"'\n\t special chars"; + const history = [createHistoryItem(specialChars)]; + expect(extractSummary(history)).toBe(specialChars.trim()); + }); + + it("should handle messages with emoji", () => { + const emoji = "Test message 🎉 with emoji 🚀"; + const history = [createHistoryItem(emoji)]; + expect(extractSummary(history)).toBe(emoji); + }); + + it("should handle messages with code blocks", () => { + const codeMessage = "Here's the code:\n\`\`\`\nfunction test() {}\n\`\`\`"; + const history = [createHistoryItem(codeMessage)]; + expect(extractSummary(history)).toBe(codeMessage.trim()); + }); + + it("should handle nested JSON objects", () => { + const nestedObj = { + level1: { + level2: { + level3: { data: "deep" }, + }, + }, + }; + const history = [createHistoryItem(nestedObj)]; + expect(extractSummary(history)).toBe(JSON.stringify(nestedObj)); + }); + + it("should handle null content gracefully", () => { + const history = [createHistoryItem(null)]; + expect(extractSummary(history)).toBeUndefined(); + }); + + it("should skip messages with only whitespace variations", () => { + const history = [ + createHistoryItem("Valid message"), + createHistoryItem(" \n "), + createHistoryItem("\t\t\t"), + createHistoryItem("\r\n\r\n"), + ]; + expect(extractSummary(history)).toBe("Valid message"); + }); + + it("should handle very long words without spaces", () => { + const longWord = "a".repeat(1000); + const history = [createHistoryItem(longWord)]; + const result = extractSummary(history, 500); + expect(result?.length).toBe(500); + expect(result?.endsWith("...")).toBe(true); + }); + + it("should handle mixed content types in history", () => { + const history = [ + createHistoryItem("String"), + createHistoryItem({ type: "object" }), + createHistoryItem(["array"]), + createHistoryItem("Last string"), + ]; + expect(extractSummary(history)).toBe("Last string"); + }); + + it("should handle maxLength of 1", () => { + const history = [createHistoryItem("Test")]; + const result = extractSummary(history, 1); + expect(result?.length).toBe(1); + // Since maxLength is 1, substring(0, -2) returns empty string, then we add "..." + expect(result).toBe("..."); + }); + + it("should handle maxLength of 3", () => { + const history = [createHistoryItem("Test")]; + const result = extractSummary(history, 3); + expect(result?.length).toBe(3); + expect(result).toBe("..."); + }); + + it("should handle maxLength of 4", () => { + const history = [createHistoryItem("Test")]; + const result = extractSummary(history, 4); + // "Test" is exactly 4 chars, should not truncate + expect(result).toBe("Test"); + }); + + it("should handle multiline content with custom maxLength", () => { + const multiline = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5"; + const history = [createHistoryItem(multiline)]; + const result = extractSummary(history, 20); + expect(result?.length).toBe(20); + expect(result).toBe("Line 1\nLine 2\nLi..."); + }); + + it("should preserve unicode characters when truncating", () => { + const unicode = "🚀🚀🚀🚀🚀"; // 5 rocket emojis + const longText = unicode.repeat(100); // 500 emojis + const history = [createHistoryItem(longText)]; + const result = extractSummary(history, 50); + expect(result?.length).toBe(50); + expect(result?.endsWith("...")).toBe(true); }); }); diff --git a/package-lock.json b/package-lock.json index a4b2f97142..fa5611dbe0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -380,7 +380,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1260,7 +1259,6 @@ "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", @@ -3472,7 +3470,6 @@ "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -4412,7 +4409,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver"