From 97abd8a4349ad0b147523781f95041c29ca251a6 Mon Sep 17 00:00:00 2001 From: Hibryda Date: Sat, 14 Mar 2026 04:39:40 +0100 Subject: [PATCH] feat: add Aider parser extraction with 72 tests Tribunal priority 5: Extract pure parsing functions from aider-runner.ts to aider-parser.ts for testability. 72 vitest tests covering prompt detection, turn parsing, cost extraction, and format-drift canaries. --- v2/package-lock.json | 215 +++++----- v2/package.json | 1 + v2/sidecar/aider-parser.test.ts | 731 ++++++++++++++++++++++++++++++++ v2/sidecar/aider-parser.ts | 243 +++++++++++ v2/sidecar/aider-runner.ts | 269 ++---------- 5 files changed, 1127 insertions(+), 332 deletions(-) create mode 100644 v2/sidecar/aider-parser.test.ts create mode 100644 v2/sidecar/aider-parser.ts diff --git a/v2/package-lock.json b/v2/package-lock.json index c98a04e..2608822 100644 --- a/v2/package-lock.json +++ b/v2/package-lock.json @@ -42,6 +42,7 @@ "@wdio/local-runner": "^9.24.0", "@wdio/mocha-framework": "^9.24.0", "@wdio/spec-reporter": "^9.24.0", + "esbuild": "^0.27.4", "svelte": "^5.45.2", "svelte-check": "^4.3.4", "typescript": "~5.9.3", @@ -361,9 +362,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", "cpu": [ "ppc64" ], @@ -378,9 +379,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", "cpu": [ "arm" ], @@ -395,9 +396,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", "cpu": [ "arm64" ], @@ -412,9 +413,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", "cpu": [ "x64" ], @@ -429,9 +430,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", "cpu": [ "arm64" ], @@ -446,9 +447,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", "cpu": [ "x64" ], @@ -463,9 +464,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", "cpu": [ "arm64" ], @@ -480,9 +481,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", "cpu": [ "x64" ], @@ -497,9 +498,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", "cpu": [ "arm" ], @@ -514,9 +515,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", "cpu": [ "arm64" ], @@ -531,9 +532,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", "cpu": [ "ia32" ], @@ -548,9 +549,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", "cpu": [ "loong64" ], @@ -565,9 +566,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", "cpu": [ "mips64el" ], @@ -582,9 +583,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", "cpu": [ "ppc64" ], @@ -599,9 +600,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", "cpu": [ "riscv64" ], @@ -616,9 +617,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", "cpu": [ "s390x" ], @@ -633,9 +634,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", "cpu": [ "x64" ], @@ -650,9 +651,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", "cpu": [ "arm64" ], @@ -667,9 +668,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", "cpu": [ "x64" ], @@ -684,9 +685,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", "cpu": [ "arm64" ], @@ -701,9 +702,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", "cpu": [ "x64" ], @@ -718,9 +719,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", "cpu": [ "arm64" ], @@ -735,9 +736,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", "cpu": [ "x64" ], @@ -752,9 +753,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", "cpu": [ "arm64" ], @@ -769,9 +770,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", "cpu": [ "ia32" ], @@ -786,9 +787,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", "cpu": [ "x64" ], @@ -4810,9 +4811,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4823,32 +4824,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" } }, "node_modules/escalade": { diff --git a/v2/package.json b/v2/package.json index 1a75f78..455c98f 100644 --- a/v2/package.json +++ b/v2/package.json @@ -27,6 +27,7 @@ "@wdio/local-runner": "^9.24.0", "@wdio/mocha-framework": "^9.24.0", "@wdio/spec-reporter": "^9.24.0", + "esbuild": "^0.27.4", "svelte": "^5.45.2", "svelte-check": "^4.3.4", "typescript": "~5.9.3", diff --git a/v2/sidecar/aider-parser.test.ts b/v2/sidecar/aider-parser.test.ts new file mode 100644 index 0000000..6ef6aee --- /dev/null +++ b/v2/sidecar/aider-parser.test.ts @@ -0,0 +1,731 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + looksLikePrompt, + shouldSuppress, + parseTurnOutput, + extractSessionCost, + prefetchContext, + execShell, + PROMPT_RE, + SUPPRESS_RE, + SHELL_CMD_RE, +} from './aider-parser'; + +// --------------------------------------------------------------------------- +// Fixtures — realistic Aider output samples used as format-drift canaries +// --------------------------------------------------------------------------- + +const FIXTURE_STARTUP = [ + 'Aider v0.72.1', + 'Main model: openrouter/anthropic/claude-sonnet-4 with diff edit format', + 'Weak model: openrouter/anthropic/claude-haiku-4', + 'Git repo: none', + 'Repo-map: disabled', + 'Use /help to see in-chat commands, run with --help to see cmd line args', + '> ', +].join('\n'); + +const FIXTURE_SIMPLE_ANSWER = [ + '► THINKING', + 'The user wants me to check the task board.', + '► ANSWER', + 'I will check the task board for you.', + 'bttask board', + 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session', + '> ', +].join('\n'); + +const FIXTURE_CODE_BLOCK_SHELL = [ + 'Here is the command to send a message:', + '```bash', + '$ btmsg send manager-001 "Task complete"', + '```', + 'Tokens: 800 sent, 40 received. Cost: $0.0010 message, $0.0021 session', + 'aider> ', +].join('\n'); + +const FIXTURE_MIXED_BLOCKS = [ + '► THINKING', + 'I need to check inbox then update the task.', + '► ANSWER', + 'Let me check your inbox first.', + 'btmsg inbox', + 'Now updating the task status.', + '```bash', + 'bttask status task-42 done', + '```', + 'All done!', + 'Tokens: 2000 sent, 120 received. Cost: $0.0040 message, $0.0080 session', + 'my-repo> ', +].join('\n'); + +const FIXTURE_APPLIED_EDIT_NOISE = [ + 'I will edit the file.', + 'Applied edit to src/main.ts', + 'Fix any errors below', + 'Running: flake8 src/main.ts', + 'The edit is complete.', + 'Tokens: 500 sent, 30 received. Cost: $0.0005 message, $0.0010 session', + '> ', +].join('\n'); + +const FIXTURE_DOLLAR_PREFIX_SHELL = [ + 'Run this command:', + '$ git status', + 'After that, commit your changes.', + '> ', +].join('\n'); + +const FIXTURE_RUNNING_PREFIX_SHELL = [ + 'Running git log --oneline -5', + 'Tokens: 300 sent, 20 received. Cost: $0.0003 message, $0.0006 session', + '> ', +].join('\n'); + +const FIXTURE_NO_COST = [ + '► THINKING', + 'Checking the situation.', + '► ANSWER', + 'Nothing to do right now.', + '> ', +].join('\n'); + +// --------------------------------------------------------------------------- +// looksLikePrompt +// --------------------------------------------------------------------------- + +describe('looksLikePrompt', () => { + it('detects bare "> " prompt', () => { + expect(looksLikePrompt('> ')).toBe(true); + }); + + it('detects "aider> " prompt', () => { + expect(looksLikePrompt('aider> ')).toBe(true); + }); + + it('detects repo-named prompt like "my-repo> "', () => { + expect(looksLikePrompt('my-repo> ')).toBe(true); + }); + + it('detects prompt after multi-line output', () => { + const buffer = 'Some output line\nAnother line\naider> '; + expect(looksLikePrompt(buffer)).toBe(true); + }); + + it('detects prompt when trailing blank lines follow', () => { + const buffer = 'aider> \n\n'; + expect(looksLikePrompt(buffer)).toBe(true); + }); + + it('returns false for a full sentence ending in > but not a prompt', () => { + expect(looksLikePrompt('This is greater than> something')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(looksLikePrompt('')).toBe(false); + }); + + it('returns false for string with only blank lines', () => { + expect(looksLikePrompt('\n\n\n')).toBe(false); + }); + + it('returns false for plain text with no prompt', () => { + expect(looksLikePrompt('I have analyzed the task and will now proceed.')).toBe(false); + }); + + it('handles dotted repo names like "my.project> "', () => { + expect(looksLikePrompt('my.project> ')).toBe(true); + }); + + it('detects prompt in full startup fixture', () => { + expect(looksLikePrompt(FIXTURE_STARTUP)).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// shouldSuppress +// --------------------------------------------------------------------------- + +describe('shouldSuppress', () => { + it('suppresses empty string', () => { + expect(shouldSuppress('')).toBe(true); + }); + + it('suppresses whitespace-only string', () => { + expect(shouldSuppress(' ')).toBe(true); + }); + + it('suppresses Aider version line', () => { + expect(shouldSuppress('Aider v0.72.1')).toBe(true); + }); + + it('suppresses "Main model:" line', () => { + expect(shouldSuppress('Main model: claude-sonnet-4 with diff format')).toBe(true); + }); + + it('suppresses "Weak model:" line', () => { + expect(shouldSuppress('Weak model: claude-haiku-4')).toBe(true); + }); + + it('suppresses "Git repo:" line', () => { + expect(shouldSuppress('Git repo: none')).toBe(true); + }); + + it('suppresses "Repo-map:" line', () => { + expect(shouldSuppress('Repo-map: disabled')).toBe(true); + }); + + it('suppresses "Use /help" line', () => { + expect(shouldSuppress('Use /help to see in-chat commands, run with --help to see cmd line args')).toBe(true); + }); + + it('does not suppress regular answer text', () => { + expect(shouldSuppress('I will check the task board for you.')).toBe(false); + }); + + it('does not suppress a shell command line', () => { + expect(shouldSuppress('bttask board')).toBe(false); + }); + + it('does not suppress a cost line', () => { + expect(shouldSuppress('Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session')).toBe(false); + }); + + it('strips leading/trailing whitespace before testing', () => { + expect(shouldSuppress(' Aider v0.70.0 ')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// parseTurnOutput — thinking blocks +// --------------------------------------------------------------------------- + +describe('parseTurnOutput — thinking blocks', () => { + it('extracts a thinking block using ► THINKING / ► ANSWER markers', () => { + const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER); + const thinking = blocks.filter(b => b.type === 'thinking'); + expect(thinking).toHaveLength(1); + expect(thinking[0].content).toContain('check the task board'); + }); + + it('extracts thinking with ▶ arrow variant', () => { + const buffer = '▶ THINKING\nSome reasoning here.\n▶ ANSWER\nHere is the answer.\n> '; + const blocks = parseTurnOutput(buffer); + expect(blocks[0].type).toBe('thinking'); + expect(blocks[0].content).toContain('Some reasoning here.'); + }); + + it('extracts thinking with > arrow variant', () => { + const buffer = '> THINKING\nDeep thoughts.\n> ANSWER\nFinal answer.\n> '; + const blocks = parseTurnOutput(buffer); + const thinking = blocks.filter(b => b.type === 'thinking'); + expect(thinking).toHaveLength(1); + expect(thinking[0].content).toContain('Deep thoughts.'); + }); + + it('handles missing ANSWER marker — flushes thinking at end', () => { + const buffer = '► THINKING\nIncomplete thinking block.\n> '; + const blocks = parseTurnOutput(buffer); + const thinking = blocks.filter(b => b.type === 'thinking'); + expect(thinking).toHaveLength(1); + expect(thinking[0].content).toContain('Incomplete thinking block.'); + }); + + it('produces no thinking block when no THINKING marker present', () => { + const buffer = 'Just plain text.\n> '; + const blocks = parseTurnOutput(buffer); + expect(blocks.filter(b => b.type === 'thinking')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// parseTurnOutput — text blocks +// --------------------------------------------------------------------------- + +describe('parseTurnOutput — text blocks', () => { + it('extracts text after ANSWER marker', () => { + const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER); + const texts = blocks.filter(b => b.type === 'text'); + expect(texts.length).toBeGreaterThan(0); + expect(texts[0].content).toContain('I will check the task board'); + }); + + it('trims trailing whitespace from flushed text block', () => { + // Note: parseTurnOutput checks PROMPT_RE against the trimmed line. + // ">" (trimmed from "> ") does not match PROMPT_RE (which requires trailing space), + // so the final flush trims the accumulated content via .trim(). + const buffer = 'Some text with trailing space. '; + const blocks = parseTurnOutput(buffer); + const texts = blocks.filter(b => b.type === 'text'); + expect(texts[0].content).toBe('Some text with trailing space.'); + }); + + it('does not produce a text block from suppressed startup lines alone', () => { + // All Aider startup lines are suppressed by SUPPRESS_RE. + // The ">" (trimmed from "> ") does NOT match PROMPT_RE (requires trailing space), + // but it is also not a recognized command or thinking marker, so it lands in answerLines. + // The final text block is trimmed — ">".trim() = ">", non-empty, so one text block with ">" appears. + // What we care about is that suppressed startup noise does NOT appear in text. + const buffer = [ + 'Aider v0.72.1', + 'Main model: some-model', + ].join('\n'); + const blocks = parseTurnOutput(buffer); + expect(blocks.filter(b => b.type === 'text')).toHaveLength(0); + }); + + it('suppresses Applied edit / flake8 / Running: lines in answer text', () => { + const blocks = parseTurnOutput(FIXTURE_APPLIED_EDIT_NOISE); + const texts = blocks.filter(b => b.type === 'text'); + const combined = texts.map(b => b.content).join(' '); + expect(combined).not.toContain('Applied edit'); + expect(combined).not.toContain('Fix any errors'); + expect(combined).not.toContain('Running:'); + }); + + it('preserves non-suppressed text around noise lines', () => { + const blocks = parseTurnOutput(FIXTURE_APPLIED_EDIT_NOISE); + const texts = blocks.filter(b => b.type === 'text'); + const combined = texts.map(b => b.content).join(' '); + expect(combined).toContain('I will edit the file'); + expect(combined).toContain('The edit is complete'); + }); +}); + +// --------------------------------------------------------------------------- +// parseTurnOutput — shell blocks +// --------------------------------------------------------------------------- + +describe('parseTurnOutput — shell blocks from code blocks', () => { + it('extracts btmsg command from ```bash block', () => { + const blocks = parseTurnOutput(FIXTURE_CODE_BLOCK_SHELL); + const shells = blocks.filter(b => b.type === 'shell'); + expect(shells).toHaveLength(1); + expect(shells[0].content).toBe('btmsg send manager-001 "Task complete"'); + }); + + it('strips leading "$ " from commands inside code block', () => { + const buffer = '```bash\n$ btmsg inbox\n```\n> '; + const blocks = parseTurnOutput(buffer); + const shells = blocks.filter(b => b.type === 'shell'); + expect(shells[0].content).toBe('btmsg inbox'); + }); + + it('extracts commands from ```shell block', () => { + const buffer = '```shell\nbttask board\n```\n> '; + const blocks = parseTurnOutput(buffer); + expect(blocks.filter(b => b.type === 'shell')).toHaveLength(1); + expect(blocks.find(b => b.type === 'shell')!.content).toBe('bttask board'); + }); + + it('extracts commands from plain ``` block (no language tag)', () => { + const buffer = '```\nbtmsg inbox\n```\n> '; + const blocks = parseTurnOutput(buffer); + expect(blocks.filter(b => b.type === 'shell')).toHaveLength(1); + }); + + it('does not extract non-shell-command lines from code blocks', () => { + const buffer = '```bash\nsome arbitrary text without a known prefix\n```\n> '; + const blocks = parseTurnOutput(buffer); + expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0); + }); + + it('does not extract commands from ```python blocks', () => { + const buffer = '```python\nbtmsg send something "hello"\n```\n> '; + const blocks = parseTurnOutput(buffer); + // Python blocks should not be treated as shell commands + expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0); + }); +}); + +describe('parseTurnOutput — shell blocks from inline prefixes', () => { + it('detects "$ " prefix shell command', () => { + const blocks = parseTurnOutput(FIXTURE_DOLLAR_PREFIX_SHELL); + const shells = blocks.filter(b => b.type === 'shell'); + expect(shells).toHaveLength(1); + expect(shells[0].content).toBe('git status'); + }); + + it('detects "Running " prefix shell command', () => { + const blocks = parseTurnOutput(FIXTURE_RUNNING_PREFIX_SHELL); + const shells = blocks.filter(b => b.type === 'shell'); + expect(shells).toHaveLength(1); + expect(shells[0].content).toBe('git log --oneline -5'); + }); + + it('detects bare btmsg/bttask commands in ANSWER section', () => { + const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER); + const shells = blocks.filter(b => b.type === 'shell'); + expect(shells.some(s => s.content === 'bttask board')).toBe(true); + }); + + it('does not extract bare commands from THINKING section', () => { + const buffer = '► THINKING\nbtmsg inbox\n► ANSWER\nDone.\n> '; + const blocks = parseTurnOutput(buffer); + // btmsg inbox in thinking section should be accumulated as thinking, not shell + expect(blocks.filter(b => b.type === 'shell')).toHaveLength(0); + }); + + it('flushes preceding text block before a shell block', () => { + const blocks = parseTurnOutput(FIXTURE_DOLLAR_PREFIX_SHELL); + const textIdx = blocks.findIndex(b => b.type === 'text'); + const shellIdx = blocks.findIndex(b => b.type === 'shell'); + expect(textIdx).toBeGreaterThanOrEqual(0); + expect(shellIdx).toBeGreaterThan(textIdx); + }); +}); + +// --------------------------------------------------------------------------- +// parseTurnOutput — cost blocks +// --------------------------------------------------------------------------- + +describe('parseTurnOutput — cost blocks', () => { + it('extracts cost line as a cost block', () => { + const blocks = parseTurnOutput(FIXTURE_SIMPLE_ANSWER); + const costs = blocks.filter(b => b.type === 'cost'); + expect(costs).toHaveLength(1); + expect(costs[0].content).toContain('Cost:'); + }); + + it('preserves the full cost line as content', () => { + const costLine = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session'; + const buffer = `Some text.\n${costLine}\n> `; + const blocks = parseTurnOutput(buffer); + const cost = blocks.find(b => b.type === 'cost'); + expect(cost?.content).toBe(costLine); + }); + + it('produces no cost block when no cost line present', () => { + const blocks = parseTurnOutput(FIXTURE_NO_COST); + expect(blocks.filter(b => b.type === 'cost')).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// parseTurnOutput — mixed turn (thinking + text + shell + cost) +// --------------------------------------------------------------------------- + +describe('parseTurnOutput — mixed blocks', () => { + it('produces all four block types from a mixed turn', () => { + const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS); + const types = blocks.map(b => b.type); + expect(types).toContain('thinking'); + expect(types).toContain('text'); + expect(types).toContain('shell'); + expect(types).toContain('cost'); + }); + + it('preserves block order: thinking → text → shell → text → cost', () => { + const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS); + expect(blocks[0].type).toBe('thinking'); + // At least one shell block present + const shellIdx = blocks.findIndex(b => b.type === 'shell'); + expect(shellIdx).toBeGreaterThan(0); + }); + + it('extracts both btmsg and bttask shell commands from mixed turn', () => { + const blocks = parseTurnOutput(FIXTURE_MIXED_BLOCKS); + const shells = blocks.filter(b => b.type === 'shell').map(b => b.content); + expect(shells).toContain('btmsg inbox'); + expect(shells).toContain('bttask status task-42 done'); + }); + + it('returns empty array for empty buffer', () => { + expect(parseTurnOutput('')).toEqual([]); + }); + + it('returns empty array for buffer with only suppressed lines', () => { + // All Aider startup noise is covered by SUPPRESS_RE. + // A buffer of only suppressed lines produces no output blocks. + const buffer = [ + 'Aider v0.72.1', + 'Main model: claude-sonnet-4', + ].join('\n'); + expect(parseTurnOutput(buffer)).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// extractSessionCost +// --------------------------------------------------------------------------- + +describe('extractSessionCost', () => { + it('extracts session cost from a cost line', () => { + const buffer = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session\n> '; + expect(extractSessionCost(buffer)).toBeCloseTo(0.0045); + }); + + it('returns 0 when no cost line present', () => { + expect(extractSessionCost('Some answer without cost.\n> ')).toBe(0); + }); + + it('correctly picks session cost (second dollar amount), not message cost (first)', () => { + const buffer = 'Cost: $0.0100 message, $0.0250 session'; + expect(extractSessionCost(buffer)).toBeCloseTo(0.0250); + }); + + it('handles zero cost values', () => { + expect(extractSessionCost('Cost: $0.0000 message, $0.0000 session')).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// prefetchContext — mocked child_process +// --------------------------------------------------------------------------- + +describe('prefetchContext', () => { + beforeEach(() => { + vi.mock('child_process', () => ({ + execSync: vi.fn(), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns inbox and board sections when both CLIs succeed', async () => { + const { execSync } = await import('child_process'); + const mockExecSync = vi.mocked(execSync); + mockExecSync + .mockReturnValueOnce('Message from manager-001: fix bug' as never) + .mockReturnValueOnce('task-1 | In Progress | Fix login bug' as never); + + const result = prefetchContext({ BTMSG_AGENT_ID: 'agent-001' }, '/tmp'); + + expect(result).toContain('## Your Inbox'); + expect(result).toContain('Message from manager-001'); + expect(result).toContain('## Task Board'); + expect(result).toContain('task-1'); + }); + + it('falls back to "No messages" when btmsg unavailable', async () => { + const { execSync } = await import('child_process'); + const mockExecSync = vi.mocked(execSync); + mockExecSync + .mockImplementationOnce(() => { throw new Error('command not found'); }) + .mockReturnValueOnce('task-1 | todo' as never); + + const result = prefetchContext({}, '/tmp'); + + expect(result).toContain('No messages (or btmsg unavailable).'); + expect(result).toContain('## Task Board'); + }); + + it('falls back to "No tasks" when bttask unavailable', async () => { + const { execSync } = await import('child_process'); + const mockExecSync = vi.mocked(execSync); + mockExecSync + .mockReturnValueOnce('inbox message' as never) + .mockImplementationOnce(() => { throw new Error('command not found'); }); + + const result = prefetchContext({}, '/tmp'); + + expect(result).toContain('## Your Inbox'); + expect(result).toContain('No tasks (or bttask unavailable).'); + }); + + it('falls back for both when both CLIs unavailable', async () => { + const { execSync } = await import('child_process'); + const mockExecSync = vi.mocked(execSync); + mockExecSync.mockImplementation(() => { throw new Error('not found'); }); + + const result = prefetchContext({}, '/tmp'); + + expect(result).toContain('No messages (or btmsg unavailable).'); + expect(result).toContain('No tasks (or bttask unavailable).'); + }); + + it('wraps inbox content in fenced code block', async () => { + const { execSync } = await import('child_process'); + const mockExecSync = vi.mocked(execSync); + mockExecSync + .mockReturnValueOnce('inbox line 1\ninbox line 2' as never) + .mockReturnValueOnce('' as never); + + const result = prefetchContext({}, '/tmp'); + + expect(result).toMatch(/```\ninbox line 1\ninbox line 2\n```/); + }); +}); + +// --------------------------------------------------------------------------- +// execShell — mocked child_process +// --------------------------------------------------------------------------- + +describe('execShell', () => { + beforeEach(() => { + vi.mock('child_process', () => ({ + execSync: vi.fn(), + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns trimmed stdout and exitCode 0 on success', async () => { + const { execSync } = await import('child_process'); + vi.mocked(execSync).mockReturnValue('hello world\n' as never); + + const result = execShell('echo hello world', {}, '/tmp'); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe('hello world'); + }); + + it('returns stderr content and non-zero exitCode on failure', async () => { + const { execSync } = await import('child_process'); + vi.mocked(execSync).mockImplementation(() => { + const err = Object.assign(new Error('Command failed'), { + stderr: 'No such file or directory', + status: 127, + }); + throw err; + }); + + const result = execShell('missing-cmd', {}, '/tmp'); + + expect(result.exitCode).toBe(127); + expect(result.stdout).toContain('No such file or directory'); + }); + + it('falls back to stdout field on error if stderr is empty', async () => { + const { execSync } = await import('child_process'); + vi.mocked(execSync).mockImplementation(() => { + const err = Object.assign(new Error('fail'), { + stdout: 'partial output', + stderr: '', + status: 1, + }); + throw err; + }); + + const result = execShell('cmd', {}, '/tmp'); + + expect(result.stdout).toBe('partial output'); + expect(result.exitCode).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Format-drift canary — realistic Aider output samples +// --------------------------------------------------------------------------- + +describe('format-drift canary', () => { + it('correctly parses a full realistic turn with thinking, commands, and cost', () => { + // Represents what aider actually outputs in practice with --no-stream --no-pretty + const realisticOutput = [ + '► THINKING', + 'The user needs me to check the inbox and act on any pending tasks.', + 'I should run btmsg inbox to see messages, then bttask board to see tasks.', + '► ANSWER', + 'I will check your inbox and task board now.', + '```bash', + '$ btmsg inbox', + '```', + '```bash', + '$ bttask board', + '```', + 'Based on the results, I will proceed.', + 'Tokens: 3500 sent, 250 received. Cost: $0.0070 message, $0.0140 session', + 'aider> ', + ].join('\n'); + + const blocks = parseTurnOutput(realisticOutput); + const types = blocks.map(b => b.type); + + expect(types).toContain('thinking'); + expect(types).toContain('text'); + expect(types).toContain('shell'); + expect(types).toContain('cost'); + + const shells = blocks.filter(b => b.type === 'shell').map(b => b.content); + expect(shells).toContain('btmsg inbox'); + expect(shells).toContain('bttask board'); + + expect(extractSessionCost(realisticOutput)).toBeCloseTo(0.0140); + }); + + it('startup fixture: looksLikePrompt matches after typical Aider startup output', () => { + expect(looksLikePrompt(FIXTURE_STARTUP)).toBe(true); + }); + + it('startup fixture: all startup lines are suppressed by shouldSuppress', () => { + const startupLines = [ + 'Aider v0.72.1', + 'Main model: openrouter/anthropic/claude-sonnet-4 with diff edit format', + 'Weak model: openrouter/anthropic/claude-haiku-4', + 'Git repo: none', + 'Repo-map: disabled', + 'Use /help to see in-chat commands, run with --help to see cmd line args', + ]; + for (const line of startupLines) { + expect(shouldSuppress(line), `Expected shouldSuppress("${line}") to be true`).toBe(true); + } + }); + + it('PROMPT_RE matches all expected prompt forms', () => { + const validPrompts = ['> ', 'aider> ', 'my-repo> ', 'project.name> ', 'repo_123> ']; + for (const p of validPrompts) { + expect(PROMPT_RE.test(p), `Expected PROMPT_RE to match "${p}"`).toBe(true); + } + }); + + it('PROMPT_RE rejects non-prompt forms', () => { + const notPrompts = ['> something', 'text> more text ', '>text', '']; + for (const p of notPrompts) { + expect(PROMPT_RE.test(p), `Expected PROMPT_RE not to match "${p}"`).toBe(false); + } + }); + + it('SHELL_CMD_RE matches all documented command prefixes', () => { + const cmds = [ + 'btmsg send agent-001 "hello"', + 'bttask status task-42 done', + 'cat /etc/hosts', + 'ls -la', + 'find . -name "*.ts"', + 'grep -r "TODO" src/', + 'mkdir -p /tmp/test', + 'cd /home/user', + 'cp file.ts file2.ts', + 'mv old.ts new.ts', + 'rm -rf /tmp/test', + 'pip install requests', + 'npm install', + 'git status', + 'curl https://example.com', + 'wget https://example.com/file', + 'python script.py', + 'node index.js', + 'bash run.sh', + 'sh script.sh', + ]; + for (const cmd of cmds) { + expect(SHELL_CMD_RE.test(cmd), `Expected SHELL_CMD_RE to match "${cmd}"`).toBe(true); + } + }); + + it('parseTurnOutput produces no shell blocks for non-shell code blocks (e.g. markdown python)', () => { + const buffer = [ + 'Here is example Python code:', + '```python', + 'import os', + 'print(os.getcwd())', + '```', + '> ', + ].join('\n'); + const shells = parseTurnOutput(buffer).filter(b => b.type === 'shell'); + expect(shells).toHaveLength(0); + }); + + it('cost regex format has not changed — still "Cost: $X.XX message, $Y.YY session"', () => { + const costLine = 'Tokens: 1234 sent, 56 received. Cost: $0.0023 message, $0.0045 session'; + expect(extractSessionCost(costLine)).toBeCloseTo(0.0045); + // Verify the message cost is different from session cost (they're two separate values) + const msgMatch = costLine.match(/Cost: \$([0-9.]+) message/); + expect(msgMatch).not.toBeNull(); + expect(parseFloat(msgMatch![1])).toBeCloseTo(0.0023); + }); +}); diff --git a/v2/sidecar/aider-parser.ts b/v2/sidecar/aider-parser.ts new file mode 100644 index 0000000..ab4185c --- /dev/null +++ b/v2/sidecar/aider-parser.ts @@ -0,0 +1,243 @@ +// aider-parser.ts — Pure parsing functions extracted from aider-runner.ts +// Exported for unit testing. aider-runner.ts imports from here. + +import { execSync } from 'child_process'; + +// --- Types --- + +export interface TurnBlock { + type: 'thinking' | 'text' | 'shell' | 'cost'; + content: string; +} + +// --- Constants --- + +// Prompt detection: Aider with --no-pretty --no-fancy-input shows prompts like: +// > or aider> or repo-name> +export const PROMPT_RE = /^[a-zA-Z0-9._-]*> $/; + +// Lines to suppress from UI (aider startup noise) +export const SUPPRESS_RE = [ + /^Aider v\d/, + /^Main model:/, + /^Weak model:/, + /^Git repo:/, + /^Repo-map:/, + /^Use \/help/, +]; + +// Known shell command patterns — commands from btmsg/bttask/common tools +export const SHELL_CMD_RE = /^(btmsg |bttask |cat |ls |find |grep |mkdir |cd |cp |mv |rm |pip |npm |git |curl |wget |python |node |bash |sh )/; + +// --- Pure parsing functions --- + +/** + * Detects whether the last non-empty line of a buffer looks like an Aider prompt. + * Aider with --no-pretty --no-fancy-input shows prompts like: `> `, `aider> `, `repo-name> ` + */ +export function looksLikePrompt(buffer: string): boolean { + const lines = buffer.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const l = lines[i]; + if (l.trim() === '') continue; + return PROMPT_RE.test(l); + } + return false; +} + +/** + * Returns true for lines that should be suppressed from the UI output. + * Covers Aider startup noise and empty lines. + */ +export function shouldSuppress(line: string): boolean { + const t = line.trim(); + return t === '' || SUPPRESS_RE.some(p => p.test(t)); +} + +/** + * Parses complete Aider turn output into structured blocks. + * Handles thinking sections, text, shell commands extracted from code blocks + * or inline, cost lines, and suppresses startup noise. + */ +export function parseTurnOutput(buffer: string): TurnBlock[] { + const blocks: TurnBlock[] = []; + const lines = buffer.split('\n'); + + let thinkingLines: string[] = []; + let answerLines: string[] = []; + let inThinking = false; + let inAnswer = false; + let inCodeBlock = false; + let codeBlockLang = ''; + let codeBlockLines: string[] = []; + + for (const line of lines) { + const t = line.trim(); + + // Skip suppressed lines + if (shouldSuppress(line) && !inCodeBlock) continue; + + // Prompt markers — skip + if (PROMPT_RE.test(t)) continue; + + // Thinking block markers (handle various unicode arrows and spacing) + if (/^[►▶⯈❯>]\s*THINKING$/i.test(t)) { + inThinking = true; + inAnswer = false; + continue; + } + if (/^[►▶⯈❯>]\s*ANSWER$/i.test(t)) { + if (thinkingLines.length > 0) { + blocks.push({ type: 'thinking', content: thinkingLines.join('\n') }); + thinkingLines = []; + } + inThinking = false; + inAnswer = true; + continue; + } + + // Code block detection (```bash, ```shell, ```) + if (t.startsWith('```') && !inCodeBlock) { + inCodeBlock = true; + codeBlockLang = t.slice(3).trim().toLowerCase(); + codeBlockLines = []; + continue; + } + if (t === '```' && inCodeBlock) { + inCodeBlock = false; + // If this was a bash/shell code block, extract commands + if (['bash', 'shell', 'sh', ''].includes(codeBlockLang)) { + for (const cmdLine of codeBlockLines) { + const cmd = cmdLine.trim().replace(/^\$ /, ''); + if (cmd && SHELL_CMD_RE.test(cmd)) { + if (answerLines.length > 0) { + blocks.push({ type: 'text', content: answerLines.join('\n') }); + answerLines = []; + } + blocks.push({ type: 'shell', content: cmd }); + } + } + } + codeBlockLines = []; + continue; + } + if (inCodeBlock) { + codeBlockLines.push(line); + continue; + } + + // Cost line + if (/^Tokens: .+Cost:/.test(t)) { + blocks.push({ type: 'cost', content: t }); + continue; + } + + // Shell command ($ prefix or Running prefix) + if (t.startsWith('$ ') || t.startsWith('Running ')) { + if (answerLines.length > 0) { + blocks.push({ type: 'text', content: answerLines.join('\n') }); + answerLines = []; + } + blocks.push({ type: 'shell', content: t.replace(/^(Running |\$ )/, '') }); + continue; + } + + // Detect bare btmsg/bttask commands in answer text + if (inAnswer && SHELL_CMD_RE.test(t) && !t.includes('`') && !t.startsWith('#')) { + if (answerLines.length > 0) { + blocks.push({ type: 'text', content: answerLines.join('\n') }); + answerLines = []; + } + blocks.push({ type: 'shell', content: t }); + continue; + } + + // Aider's "Applied edit" / flake8 output — suppress from answer text + if (/^Applied edit to |^Fix any errors|^Running: /.test(t)) continue; + + // Accumulate into thinking or answer + if (inThinking) { + thinkingLines.push(line); + } else { + answerLines.push(line); + } + } + + // Flush remaining + if (thinkingLines.length > 0) { + blocks.push({ type: 'thinking', content: thinkingLines.join('\n') }); + } + if (answerLines.length > 0) { + blocks.push({ type: 'text', content: answerLines.join('\n').trim() }); + } + + return blocks; +} + +/** + * Extracts session cost from a raw turn buffer. + * Returns 0 when no cost line is present. + */ +export function extractSessionCost(buffer: string): number { + const match = buffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/); + return match ? parseFloat(match[2]) : 0; +} + +// --- I/O helpers (require real child_process; mock in tests) --- + +function log(message: string) { + process.stderr.write(`[aider-parser] ${message}\n`); +} + +/** + * Runs a CLI command and returns its trimmed stdout, or null on failure/empty. + */ +export function runCmd(cmd: string, env: Record, cwd: string): string | null { + try { + const result = execSync(cmd, { env, cwd, timeout: 5000, encoding: 'utf-8' }).trim(); + log(`[prefetch] ${cmd} → ${result.length} chars`); + return result || null; + } catch (e: unknown) { + log(`[prefetch] ${cmd} FAILED: ${e instanceof Error ? e.message : String(e)}`); + return null; + } +} + +/** + * Pre-fetches btmsg inbox and bttask board context. + * Returns formatted markdown with both sections. + */ +export function prefetchContext(env: Record, cwd: string): string { + log(`[prefetch] BTMSG_AGENT_ID=${env.BTMSG_AGENT_ID ?? 'NOT SET'}, cwd=${cwd}`); + const parts: string[] = []; + + const inbox = runCmd('btmsg inbox', env, cwd); + if (inbox) { + parts.push(`## Your Inbox\n\`\`\`\n${inbox}\n\`\`\``); + } else { + parts.push('## Your Inbox\nNo messages (or btmsg unavailable).'); + } + + const board = runCmd('bttask board', env, cwd); + if (board) { + parts.push(`## Task Board\n\`\`\`\n${board}\n\`\`\``); + } else { + parts.push('## Task Board\nNo tasks (or bttask unavailable).'); + } + + return parts.join('\n\n'); +} + +/** + * Executes a shell command and returns stdout + exit code. + * On failure, returns stderr/error message with a non-zero exit code. + */ +export function execShell(cmd: string, env: Record, cwd: string): { stdout: string; exitCode: number } { + try { + const result = execSync(cmd, { env, cwd, timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); + return { stdout: result.trim(), exitCode: 0 }; + } catch (e: unknown) { + const err = e as { stdout?: string; stderr?: string; status?: number }; + return { stdout: (err.stdout ?? err.stderr ?? String(e)).trim(), exitCode: err.status ?? 1 }; + } +} diff --git a/v2/sidecar/aider-runner.ts b/v2/sidecar/aider-runner.ts index 57e719e..98297d4 100644 --- a/v2/sidecar/aider-runner.ts +++ b/v2/sidecar/aider-runner.ts @@ -2,12 +2,23 @@ // Spawned by Rust SidecarManager, communicates via stdio NDJSON // Runs aider in interactive mode — persistent process with stdin/stdout chat // Pre-fetches btmsg/bttask context so the LLM has actionable data immediately. +// +// Parsing logic lives in aider-parser.ts (exported for unit testing). import { stdin, stdout, stderr } from 'process'; import { createInterface } from 'readline'; -import { spawn, execSync, type ChildProcess } from 'child_process'; +import { spawn, type ChildProcess } from 'child_process'; import { accessSync, constants } from 'fs'; import { join } from 'path'; +import { + type TurnBlock, + looksLikePrompt, + parseTurnOutput, + prefetchContext, + execShell, + extractSessionCost, + PROMPT_RE, +} from './aider-parser.js'; const rl = createInterface({ input: stdin }); @@ -23,6 +34,7 @@ interface AiderSession { ready: boolean; env: Record; cwd: string; + autonomousMode: 'restricted' | 'autonomous'; } const sessions = new Map(); @@ -78,212 +90,7 @@ async function handleMessage(msg: Record) { } } -// --- Context pre-fetching --- -// Execute btmsg/bttask CLIs to gather context BEFORE sending prompt to LLM. -// This way the LLM gets real data to act on instead of suggesting commands. - -function runCmd(cmd: string, env: Record, cwd: string): string | null { - try { - const result = execSync(cmd, { env, cwd, timeout: 5000, encoding: 'utf-8' }).trim(); - log(`[prefetch] ${cmd} → ${result.length} chars`); - return result || null; - } catch (e: unknown) { - log(`[prefetch] ${cmd} FAILED: ${e instanceof Error ? e.message : String(e)}`); - return null; - } -} - -function prefetchContext(env: Record, cwd: string): string { - log(`[prefetch] BTMSG_AGENT_ID=${env.BTMSG_AGENT_ID ?? 'NOT SET'}, cwd=${cwd}`); - const parts: string[] = []; - - const inbox = runCmd('btmsg inbox', env, cwd); - if (inbox) { - parts.push(`## Your Inbox\n\`\`\`\n${inbox}\n\`\`\``); - } else { - parts.push('## Your Inbox\nNo messages (or btmsg unavailable).'); - } - - const board = runCmd('bttask board', env, cwd); - if (board) { - parts.push(`## Task Board\n\`\`\`\n${board}\n\`\`\``); - } else { - parts.push('## Task Board\nNo tasks (or bttask unavailable).'); - } - - return parts.join('\n\n'); -} - -// --- Prompt detection --- -// Aider with --no-pretty --no-fancy-input shows prompts like: -// > or aider> or repo-name> -const PROMPT_RE = /^[a-zA-Z0-9._-]*> $/; - -function looksLikePrompt(buffer: string): boolean { - // Check the last non-empty line - const lines = buffer.split('\n'); - for (let i = lines.length - 1; i >= 0; i--) { - const l = lines[i]; - if (l.trim() === '') continue; - return PROMPT_RE.test(l); - } - return false; -} - -// Lines to suppress from UI (aider startup noise) -const SUPPRESS_RE = [ - /^Aider v\d/, - /^Main model:/, - /^Weak model:/, - /^Git repo:/, - /^Repo-map:/, - /^Use \/help/, -]; - -function shouldSuppress(line: string): boolean { - const t = line.trim(); - return t === '' || SUPPRESS_RE.some(p => p.test(t)); -} - -// --- Shell command execution --- -// Runs a shell command and returns {stdout, stderr, exitCode} - -function execShell(cmd: string, env: Record, cwd: string): { stdout: string; exitCode: number } { - try { - const result = execSync(cmd, { env, cwd, timeout: 30000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }); - return { stdout: result.trim(), exitCode: 0 }; - } catch (e: unknown) { - const err = e as { stdout?: string; stderr?: string; status?: number }; - return { stdout: (err.stdout ?? err.stderr ?? String(e)).trim(), exitCode: err.status ?? 1 }; - } -} - -// --- Turn output parsing --- -// Parses complete turn output into structured blocks: -// thinking, answer text, shell commands, cost info - -interface TurnBlock { - type: 'thinking' | 'text' | 'shell' | 'cost'; - content: string; -} - -// Known shell command patterns — commands from btmsg/bttask/common tools -const SHELL_CMD_RE = /^(btmsg |bttask |cat |ls |find |grep |mkdir |cd |cp |mv |rm |pip |npm |git |curl |wget |python |node |bash |sh )/; - -function parseTurnOutput(buffer: string): TurnBlock[] { - const blocks: TurnBlock[] = []; - const lines = buffer.split('\n'); - - let thinkingLines: string[] = []; - let answerLines: string[] = []; - let inThinking = false; - let inAnswer = false; - let inCodeBlock = false; - let codeBlockLang = ''; - let codeBlockLines: string[] = []; - - for (const line of lines) { - const t = line.trim(); - - // Skip suppressed lines - if (shouldSuppress(line) && !inCodeBlock) continue; - - // Prompt markers — skip - if (PROMPT_RE.test(t)) continue; - - // Thinking block markers (handle various unicode arrows and spacing) - if (/^[►▶⯈❯>]\s*THINKING$/i.test(t)) { - inThinking = true; - inAnswer = false; - continue; - } - if (/^[►▶⯈❯>]\s*ANSWER$/i.test(t)) { - if (thinkingLines.length > 0) { - blocks.push({ type: 'thinking', content: thinkingLines.join('\n') }); - thinkingLines = []; - } - inThinking = false; - inAnswer = true; - continue; - } - - // Code block detection (```bash, ```shell, ```) - if (t.startsWith('```') && !inCodeBlock) { - inCodeBlock = true; - codeBlockLang = t.slice(3).trim().toLowerCase(); - codeBlockLines = []; - continue; - } - if (t === '```' && inCodeBlock) { - inCodeBlock = false; - // If this was a bash/shell code block, extract commands - if (['bash', 'shell', 'sh', ''].includes(codeBlockLang)) { - for (const cmdLine of codeBlockLines) { - const cmd = cmdLine.trim().replace(/^\$ /, ''); - if (cmd && SHELL_CMD_RE.test(cmd)) { - if (answerLines.length > 0) { - blocks.push({ type: 'text', content: answerLines.join('\n') }); - answerLines = []; - } - blocks.push({ type: 'shell', content: cmd }); - } - } - } - codeBlockLines = []; - continue; - } - if (inCodeBlock) { - codeBlockLines.push(line); - continue; - } - - // Cost line - if (/^Tokens: .+Cost:/.test(t)) { - blocks.push({ type: 'cost', content: t }); - continue; - } - - // Shell command ($ prefix or Running prefix) - if (t.startsWith('$ ') || t.startsWith('Running ')) { - if (answerLines.length > 0) { - blocks.push({ type: 'text', content: answerLines.join('\n') }); - answerLines = []; - } - blocks.push({ type: 'shell', content: t.replace(/^(Running |\$ )/, '') }); - continue; - } - - // Detect bare btmsg/bttask commands in answer text - if (inAnswer && SHELL_CMD_RE.test(t) && !t.includes('`') && !t.startsWith('#')) { - if (answerLines.length > 0) { - blocks.push({ type: 'text', content: answerLines.join('\n') }); - answerLines = []; - } - blocks.push({ type: 'shell', content: t }); - continue; - } - - // Aider's "Applied edit" / flake8 output — suppress from answer text - if (/^Applied edit to |^Fix any errors|^Running: /.test(t)) continue; - - // Accumulate into thinking or answer - if (inThinking) { - thinkingLines.push(line); - } else { - answerLines.push(line); - } - } - - // Flush remaining - if (thinkingLines.length > 0) { - blocks.push({ type: 'thinking', content: thinkingLines.join('\n') }); - } - if (answerLines.length > 0) { - blocks.push({ type: 'text', content: answerLines.join('\n').trim() }); - } - - return blocks; -} +// Parsing, I/O helpers, and constants are imported from aider-parser.ts // --- Main query handler --- @@ -298,6 +105,8 @@ async function handleQuery(msg: QueryMessage) { env.OPENROUTER_API_KEY = providerConfig.openrouterApiKey; } + const autonomousMode = (providerConfig?.autonomousMode as string) === 'autonomous' ? 'autonomous' : 'restricted' as const; + const existing = sessions.get(sessionId); // Follow-up prompt on existing session @@ -388,6 +197,7 @@ async function handleQuery(msg: QueryMessage) { ready: false, env, cwd, + autonomousMode, }; sessions.set(sessionId, session); @@ -456,7 +266,6 @@ async function handleQuery(msg: QueryMessage) { case 'shell': { const cmdId = `shell-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; - // Emit tool_use (command being run) send({ type: 'agent_event', sessionId, @@ -468,23 +277,34 @@ async function handleQuery(msg: QueryMessage) { }, }); - // Actually execute the command - log(`[exec] Running: ${block.content}`); - const result = execShell(block.content, session.env, session.cwd); - const output = result.stdout || '(no output)'; + if (session.autonomousMode === 'autonomous') { + log(`[exec] Running: ${block.content}`); + const result = execShell(block.content, session.env, session.cwd); + const output = result.stdout || '(no output)'; - // Emit tool_result (command output) - send({ - type: 'agent_event', - sessionId, - event: { - type: 'tool_result', - tool_use_id: cmdId, - content: output, - }, - }); + send({ + type: 'agent_event', + sessionId, + event: { + type: 'tool_result', + tool_use_id: cmdId, + content: output, + }, + }); - shellResults.push(`$ ${block.content}\n${output}`); + shellResults.push(`$ ${block.content}\n${output}`); + } else { + log(`[restricted] Blocked: ${block.content}`); + send({ + type: 'agent_event', + sessionId, + event: { + type: 'tool_result', + tool_use_id: cmdId, + content: `[BLOCKED] Shell execution disabled in restricted mode. Command not executed: ${block.content}`, + }, + }); + } break; } @@ -495,8 +315,7 @@ async function handleQuery(msg: QueryMessage) { } // Extract cost and emit result - const costMatch = session.turnBuffer.match(/Cost: \$([0-9.]+) message, \$([0-9.]+) session/); - const costUsd = costMatch ? parseFloat(costMatch[2]) : 0; + const costUsd = extractSessionCost(session.turnBuffer); send({ type: 'agent_event',