From e74195229529e9921970ff50b5c760635bee91af Mon Sep 17 00:00:00 2001 From: kris Date: Tue, 31 Mar 2026 19:30:26 +0800 Subject: [PATCH] feat: add master agent chat controls --- package-lock.json | 520 +++++++++++ package.json | 2 + .../[projectId]/agent-controls/route.ts | 108 +++ src/app/api/v1/projects/[projectId]/route.ts | 5 +- src/lib/boss-data.ts | 168 ++++ src/lib/boss-projections.ts | 3 + tests/master-agent-chat-controls.test.ts | 827 ++++++++++++++++++ 7 files changed, 1632 insertions(+), 1 deletion(-) create mode 100644 src/app/api/v1/projects/[projectId]/agent-controls/route.ts create mode 100644 tests/master-agent-chat-controls.test.ts diff --git a/package-lock.json b/package-lock.json index cbe3b4c..c34f304 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "eslint": "^9", "eslint-config-next": "16.2.1", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5" } }, @@ -398,6 +399,448 @@ "tslib": "^2.4.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "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" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.9.1", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", @@ -3665,6 +4108,48 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/esbuild": { + "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", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@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": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -4413,6 +4898,21 @@ "node": ">=14.14" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/ftp": { "version": "0.3.10", "resolved": "https://registry.npmjs.org/ftp/-/ftp-0.3.10.tgz", @@ -8061,6 +8561,26 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", diff --git a/package.json b/package.json index 77bb35b..1f317bd 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "postbuild": "mkdir -p .next/standalone/.next && rm -rf .next/standalone/.next/static .next/standalone/public && cp -R .next/static .next/standalone/.next/static && cp -R public .next/standalone/public", "start": "BOSS_RUNTIME_ROOT=\"$PWD\" BOSS_STATE_FILE=\"$PWD/data/boss-state.json\" node .next/standalone/server.js", "lint": "eslint", + "test:master-agent-controls": "tsx --test tests/master-agent-chat-controls.test.ts", "apk:debug": "cd android && ./gradlew assembleDebug && cd .. && zsh ./scripts/publish-apk-to-public.sh", "apk:release": "zsh ./scripts/build-release-apk.sh", "aab:release": "zsh ./scripts/build-release-aab.sh" @@ -35,6 +36,7 @@ "eslint": "^9", "eslint-config-next": "16.2.1", "tailwindcss": "^4", + "tsx": "^4.21.0", "typescript": "^5" } } diff --git a/src/app/api/v1/projects/[projectId]/agent-controls/route.ts b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts new file mode 100644 index 0000000..5c684d5 --- /dev/null +++ b/src/app/api/v1/projects/[projectId]/agent-controls/route.ts @@ -0,0 +1,108 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireRequestSession } from "@/lib/boss-auth"; +import { + getProjectAgentControls, + hasPersistedProject, + updateProjectAgentControls, +} from "@/lib/boss-data"; + +const reasoningEffortValues = new Set(["low", "medium", "high"]); + +export async function GET( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const { projectId } = await context.params; + if (projectId !== "master-agent") { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const projectExists = await hasPersistedProject(projectId); + if (!projectExists) { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const controls = await getProjectAgentControls(projectId); + return NextResponse.json({ ok: true, controls }); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ projectId: string }> }, +) { + const session = await requireRequestSession(request); + if (!session) { + return NextResponse.json({ ok: false, message: "UNAUTHORIZED" }, { status: 401 }); + } + + const { projectId } = await context.params; + if (projectId !== "master-agent") { + return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); + } + if (session.role !== "highest_admin") { + return NextResponse.json({ ok: false, message: "FORBIDDEN" }, { status: 403 }); + } + + const rawBody = await request.text().catch(() => ""); + let body: unknown; + try { + body = JSON.parse(rawBody); + } catch { + return NextResponse.json({ ok: false, message: "INVALID_JSON_PAYLOAD" }, { status: 400 }); + } + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ ok: false, message: "INVALID_AGENT_CONTROLS_PAYLOAD" }, { status: 400 }); + } + + const payload = body as { + modelOverride?: unknown; + reasoningEffortOverride?: unknown; + }; + const hasModelOverride = Object.prototype.hasOwnProperty.call(payload, "modelOverride"); + const hasReasoningEffortOverride = Object.prototype.hasOwnProperty.call( + payload, + "reasoningEffortOverride", + ); + const allowedKeys = new Set(["modelOverride", "reasoningEffortOverride"]); + const hasUnsupportedKeys = Object.keys(payload).some((key) => !allowedKeys.has(key)); + if ((!hasModelOverride && !hasReasoningEffortOverride) || hasUnsupportedKeys) { + return NextResponse.json({ ok: false, message: "INVALID_AGENT_CONTROLS_PAYLOAD" }, { status: 400 }); + } + + if (hasModelOverride && payload.modelOverride !== undefined && payload.modelOverride !== null && typeof payload.modelOverride !== "string") { + return NextResponse.json({ ok: false, message: "INVALID_MODEL_OVERRIDE" }, { status: 400 }); + } + if (hasReasoningEffortOverride && payload.reasoningEffortOverride !== undefined && payload.reasoningEffortOverride !== null && typeof payload.reasoningEffortOverride !== "string") { + return NextResponse.json( + { ok: false, message: "INVALID_REASONING_EFFORT_OVERRIDE" }, + { status: 400 }, + ); + } + if (hasReasoningEffortOverride && typeof payload.reasoningEffortOverride === "string" && !reasoningEffortValues.has(payload.reasoningEffortOverride)) { + return NextResponse.json( + { ok: false, message: "INVALID_REASONING_EFFORT_OVERRIDE" }, + { status: 400 }, + ); + } + + try { + const controls = await updateProjectAgentControls( + projectId, + { + ...(hasModelOverride ? { modelOverride: payload.modelOverride } : {}), + ...(hasReasoningEffortOverride ? { reasoningEffortOverride: payload.reasoningEffortOverride } : {}), + }, + ); + return NextResponse.json({ ok: true, controls: controls ?? null }); + } catch (error) { + return NextResponse.json( + { ok: false, message: error instanceof Error ? error.message : "UNKNOWN_ERROR" }, + { status: error instanceof Error && error.message === "PROJECT_NOT_FOUND" ? 404 : 400 }, + ); + } +} diff --git a/src/app/api/v1/projects/[projectId]/route.ts b/src/app/api/v1/projects/[projectId]/route.ts index 1b8d245..a9d55f4 100644 --- a/src/app/api/v1/projects/[projectId]/route.ts +++ b/src/app/api/v1/projects/[projectId]/route.ts @@ -19,5 +19,8 @@ export async function GET( return NextResponse.json({ ok: false, message: "PROJECT_NOT_FOUND" }, { status: 404 }); } - return NextResponse.json({ ok: true, ...detail }); + return NextResponse.json({ + ok: true, + ...detail, + }); } diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index 12c5d35..b00b076 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -143,6 +143,7 @@ export type DispatchPlanStatus = | "rejected" | "dispatched"; export type DispatchExecutionStatus = "queued" | "running" | "completed" | "failed"; +export type ReasoningEffort = "low" | "medium" | "high"; export interface UserSettings { liveUpdates: boolean; @@ -266,6 +267,7 @@ export interface Project { createdByAgent: boolean; collaborationMode: "development" | "approval_required"; approvalState: "not_required" | "pending_agent" | "pending_user" | "approved" | "rejected"; + agentControls?: ProjectAgentControls; unreadCount: number; riskLevel: RiskLevel; contextBudgetPct?: number; @@ -314,6 +316,12 @@ export interface DispatchExecution { completedByDeviceId?: string; } +export interface ProjectAgentControls { + modelOverride?: string; + reasoningEffortOverride?: ReasoningEffort; + updatedAt: string; +} + export interface DeviceImportCandidate { candidateId: string; deviceId: string; @@ -1613,6 +1621,27 @@ function trimToDefined(value?: string) { return trimmed ? trimmed : undefined; } +function parseControlTextOverride(value: unknown) { + if (value === undefined || value === null) { + return { kind: "clear" as const }; + } + if (typeof value !== "string") { + return { kind: "invalid" as const }; + } + const trimmed = value.trim(); + return trimmed ? { kind: "set" as const, value: trimmed } : { kind: "clear" as const }; +} + +function parseReasoningEffortOverride(value: unknown) { + if (value === undefined || value === null) { + return { kind: "clear" as const }; + } + if (!isReasoningEffort(value)) { + return { kind: "invalid" as const }; + } + return { kind: "set" as const, value }; +} + function normalizeStringSet(values: string[]) { return dedupeStrings(values.map((value) => value.trim()).filter(Boolean)).sort((a, b) => a.localeCompare(b)); } @@ -2034,6 +2063,29 @@ function normalizeProjectConversationShape( return project; } +function normalizeProjectAgentControls( + raw: Partial | undefined, +): ProjectAgentControls | undefined { + const modelOverride = trimToDefined(raw?.modelOverride); + const reasoningEffortOverride = isReasoningEffort(raw?.reasoningEffortOverride) + ? raw.reasoningEffortOverride + : undefined; + + if (!modelOverride && !reasoningEffortOverride) { + return undefined; + } + + return { + modelOverride, + reasoningEffortOverride, + updatedAt: raw?.updatedAt ?? nowIso(), + }; +} + +function isReasoningEffort(value: unknown): value is ReasoningEffort { + return value === "low" || value === "medium" || value === "high"; +} + function resolveProjectUpdatedAt(project: Pick, latestActivityAt?: string) { return latestIsoTimestamp( project.updatedAt, @@ -2490,6 +2542,7 @@ function normalizeProject(raw: Partial, fallback?: Project): Project { createdByAgent: raw.createdByAgent ?? false, collaborationMode: raw.collaborationMode ?? "development", approvalState: raw.approvalState ?? "not_required", + agentControls: normalizeProjectAgentControls(raw.agentControls), }; project.groupMembers = ensureArray(raw.groupMembers, []).map((member) => normalizeGroupMember(member, projectId, project.threadMeta), @@ -3284,11 +3337,126 @@ async function mutateState(mutator: (state: BossState) => Promise | T) { return result; } +async function mutateStateIfChanged( + mutator: (state: BossState) => Promise<{ result: T; changed: boolean }> | { result: T; changed: boolean }, +) { + let result!: T; + const run = async () => { + const state = await readState(); + const outcome = await mutator(state); + result = outcome.result; + if (outcome.changed) { + await writeState(state); + } + }; + + stateMutationQueue = stateMutationQueue.then(run, run); + await stateMutationQueue; + return result; +} + +async function loadPersistedStateRaw() { + await ensureStateFile(); + + const parseStateText = (text: string) => JSON.parse(text) as Partial; + const tryRead = async (filePath: string) => { + const text = await fs.readFile(filePath, "utf8"); + return parseStateText(text); + }; + + try { + return await tryRead(dataFile); + } catch { + try { + return await tryRead(backupFile); + } catch { + if (lastPersistedStateText) { + return parseStateText(lastPersistedStateText); + } + return JSON.parse(JSON.stringify(syncDerivedState(cloneInitialState()))) as Partial; + } + } +} + export async function getProject(projectId: string) { const state = await readState(); return state.projects.find((project) => project.id === projectId) ?? null; } +export async function hasPersistedProject(projectId: string) { + const rawState = await loadPersistedStateRaw(); + return Array.isArray(rawState.projects) && rawState.projects.some((project) => project?.id === projectId); +} + +export async function getProjectAgentControls(projectId: string) { + if (projectId !== "master-agent") { + return null; + } + const state = await readState(); + return state.projects.find((project) => project.id === projectId)?.agentControls ?? null; +} + +export async function updateProjectAgentControls( + projectId: string, + payload: { + modelOverride?: unknown; + reasoningEffortOverride?: unknown; + }, +) { + if (projectId !== "master-agent") { + throw new Error("MASTER_AGENT_CONTROLS_SCOPE_RESTRICTED"); + } + + const modelOverrideInput = Object.prototype.hasOwnProperty.call(payload, "modelOverride") + ? parseControlTextOverride(payload.modelOverride) + : { kind: "preserve" as const }; + const reasoningEffortInput = Object.prototype.hasOwnProperty.call(payload, "reasoningEffortOverride") + ? parseReasoningEffortOverride(payload.reasoningEffortOverride) + : { kind: "preserve" as const }; + if (modelOverrideInput.kind === "invalid") { + throw new Error("INVALID_MODEL_OVERRIDE"); + } + if (reasoningEffortInput.kind === "invalid") { + throw new Error("INVALID_REASONING_EFFORT_OVERRIDE"); + } + + return mutateStateIfChanged((state) => { + const project = state.projects.find((item) => item.id === projectId); + if (!project) throw new Error("PROJECT_NOT_FOUND"); + + const currentControls = project.agentControls; + const modelOverride = + modelOverrideInput.kind === "set" + ? modelOverrideInput.value + : modelOverrideInput.kind === "clear" + ? undefined + : currentControls?.modelOverride; + const reasoningEffortOverride = + reasoningEffortInput.kind === "set" + ? reasoningEffortInput.value + : reasoningEffortInput.kind === "clear" + ? undefined + : currentControls?.reasoningEffortOverride; + + const currentModelOverride = currentControls?.modelOverride; + const currentReasoningEffortOverride = currentControls?.reasoningEffortOverride; + if (currentModelOverride === modelOverride && currentReasoningEffortOverride === reasoningEffortOverride) { + return { result: currentControls, changed: false }; + } + + const nextControls = { + modelOverride, + reasoningEffortOverride, + updatedAt: nowIso(), + } satisfies ProjectAgentControls; + + project.agentControls = normalizeProjectAgentControls(nextControls); + project.threadMeta.updatedAt = nextControls.updatedAt; + project.updatedAt = nextControls.updatedAt; + return { result: project.agentControls, changed: true }; + }); +} + export async function getDevice(deviceId: string) { const state = await readState(); return state.devices.find((device) => device.id === deviceId) ?? null; diff --git a/src/lib/boss-projections.ts b/src/lib/boss-projections.ts index bbe1683..9f79e4b 100644 --- a/src/lib/boss-projections.ts +++ b/src/lib/boss-projections.ts @@ -18,6 +18,7 @@ import type { OpsRepairTicket, OpsRepairVerification, Project, + ProjectAgentControls, RiskLevel, ThreadContextAlert, ThreadContextSnapshot, @@ -75,6 +76,7 @@ export interface ThreadContextView { export interface ProjectDetailView { project: Project; + agentControls?: ProjectAgentControls | null; devices: Device[]; masterIdentity?: MasterIdentitySummary; activeThreadContexts: ThreadContextView[]; @@ -542,6 +544,7 @@ export function getProjectDetailView(state: BossState, projectId: string): Proje return { project, + agentControls: project.id === "master-agent" ? project.agentControls ?? null : undefined, devices: state.devices.filter((device) => project.deviceIds.includes(device.id)), masterIdentity: projectId === "master-agent" ? getProjectMasterIdentity(state) : undefined, activeThreadContexts, diff --git a/tests/master-agent-chat-controls.test.ts b/tests/master-agent-chat-controls.test.ts new file mode 100644 index 0000000..384d87f --- /dev/null +++ b/tests/master-agent-chat-controls.test.ts @@ -0,0 +1,827 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import os from "node:os"; +import path from "node:path"; +import { mkdtemp, rm } from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { promisify } from "node:util"; +import { NextRequest } from "next/server"; + +let runtimeRoot = ""; +let readState: (typeof import("../src/lib/boss-data"))["readState"]; +let writeState: (typeof import("../src/lib/boss-data"))["writeState"]; +let updateProjectAgentControls: (typeof import("../src/lib/boss-data"))["updateProjectAgentControls"]; +let getProjectAgentControls: (typeof import("../src/lib/boss-data"))["getProjectAgentControls"]; +let getProjectDetailView: (typeof import("../src/lib/boss-projections"))["getProjectDetailView"]; +let getProjectRoute: (typeof import("../src/app/api/v1/projects/[projectId]/route"))["GET"]; +let getAgentControlsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/agent-controls/route"))["GET"]; +let postAgentControlsRoute: (typeof import("../src/app/api/v1/projects/[projectId]/agent-controls/route"))["POST"]; +let createAuthSession: (typeof import("../src/lib/boss-data"))["createAuthSession"]; +let AUTH_SESSION_COOKIE = ""; +const execFile = promisify(execFileCallback); + +async function setup() { + if (runtimeRoot) return; + + runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-controls-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const [data, projections, projectRouteModule, agentControlsRouteModule, auth] = await Promise.all([ + import("../src/lib/boss-data.ts"), + import("../src/lib/boss-projections.ts"), + import("../src/app/api/v1/projects/[projectId]/route.ts"), + import("../src/app/api/v1/projects/[projectId]/agent-controls/route.ts"), + import("../src/lib/boss-auth.ts"), + ]); + + readState = data.readState; + writeState = data.writeState; + updateProjectAgentControls = data.updateProjectAgentControls; + getProjectAgentControls = data.getProjectAgentControls; + getProjectDetailView = projections.getProjectDetailView; + getProjectRoute = projectRouteModule.GET; + getAgentControlsRoute = agentControlsRouteModule.GET; + postAgentControlsRoute = agentControlsRouteModule.POST; + createAuthSession = data.createAuthSession; + AUTH_SESSION_COOKIE = auth.AUTH_SESSION_COOKIE; +} + +async function resetMasterAgentControls() { + await setup(); + const state = await readState(); + const project = state.projects.find((item) => item.id === "master-agent"); + assert.ok(project, "expected seeded master-agent project"); + delete project.agentControls; + await writeState(state); +} + +test.beforeEach(async () => { + await resetMasterAgentControls(); +}); + +test.after(async () => { + if (runtimeRoot) { + await rm(runtimeRoot, { recursive: true, force: true }); + } +}); + +test("master-agent 会话可保存并读取模型与推理强度覆盖", async () => { + await setup(); + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls?.modelOverride, "gpt-5.4"); + assert.equal(controls?.reasoningEffortOverride, "high"); + + const state = await readState(); + const project = state.projects.find((item) => item.id === "master-agent"); + assert.equal(project?.agentControls?.modelOverride, "gpt-5.4"); + assert.equal(project?.agentControls?.reasoningEffortOverride, "high"); + + const detail = getProjectDetailView(state, "master-agent"); + assert.equal(detail?.agentControls?.modelOverride, "gpt-5.4"); + assert.equal(detail?.agentControls?.reasoningEffortOverride, "high"); +}); + +test("master-agent 对话控制路由可读写并回显到项目详情", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const headers = { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }; + + const postResponse = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({ + modelOverride: "gpt-5.4", + reasoningEffortOverride: "medium", + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(postResponse.status, 200); + + const postPayload = (await postResponse.json()) as { + ok: boolean; + controls: { + modelOverride?: string; + reasoningEffortOverride?: string; + updatedAt: string; + } | null; + }; + assert.equal(postPayload.ok, true); + assert.equal(postPayload.controls?.modelOverride, "gpt-5.4"); + assert.equal(postPayload.controls?.reasoningEffortOverride, "medium"); + + const getResponse = await getAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "GET", + headers, + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(getResponse.status, 200); + + const getPayload = (await getResponse.json()) as { + ok: boolean; + controls: { + modelOverride?: string; + reasoningEffortOverride?: string; + updatedAt: string; + } | null; + }; + assert.equal(getPayload.ok, true); + assert.equal(getPayload.controls?.modelOverride, "gpt-5.4"); + assert.equal(getPayload.controls?.reasoningEffortOverride, "medium"); + + const projectResponse = await getProjectRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent", { + method: "GET", + headers, + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(projectResponse.status, 200); + + const projectPayload = (await projectResponse.json()) as { + ok: boolean; + agentControls: { + modelOverride?: string; + reasoningEffortOverride?: string; + updatedAt: string; + } | null; + }; + assert.equal(projectPayload.ok, true); + assert.equal(projectPayload.agentControls?.modelOverride, "gpt-5.4"); + assert.equal(projectPayload.agentControls?.reasoningEffortOverride, "medium"); +}); + +test("master-agent 对话控制路由单字段更新不会清掉另一字段", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const headers = { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }; + + await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({ + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + const response = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({ + reasoningEffortOverride: "low", + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as { + ok: boolean; + controls: { + modelOverride?: string; + reasoningEffortOverride?: string; + } | null; + }; + assert.equal(payload.ok, true); + assert.equal(payload.controls?.modelOverride, "gpt-5.4"); + assert.equal(payload.controls?.reasoningEffortOverride, "low"); +}); + +test("master-agent 对话控制 POST 清空后仍稳定回传 controls null", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const headers = { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }; + + await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({ + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + const clearResponse = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({ + modelOverride: null, + reasoningEffortOverride: null, + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(clearResponse.status, 200); + const clearPayload = (await clearResponse.json()) as { + ok: boolean; + controls: unknown; + }; + assert.equal(clearPayload.ok, true); + assert.equal(Object.prototype.hasOwnProperty.call(clearPayload, "controls"), true); + assert.equal(clearPayload.controls, null); +}); + +test("非 master-agent 项目详情不应回传 agentControls 字段", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const response = await getProjectRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/boss-console", { + method: "GET", + headers: { + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + }), + { params: Promise.resolve({ projectId: "boss-console" }) }, + ); + + assert.equal(response.status, 200); + const payload = (await response.json()) as Record; + assert.equal(payload.ok, true); + assert.equal(Object.prototype.hasOwnProperty.call(payload, "agentControls"), false); +}); + +test("master-agent 对话控制 POST 仅允许 highest_admin 修改", async () => { + await setup(); + + const session = await createAuthSession({ + account: "viewer-0001", + role: "member", + displayName: "普通成员", + loginMethod: "password", + }); + + const response = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify({ + modelOverride: "gpt-5.4", + reasoningEffortOverride: "low", + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(response.status, 403); + + const payload = (await response.json()) as { ok: boolean; message: string }; + assert.equal(payload.ok, false); + assert.equal(payload.message, "FORBIDDEN"); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls, null); +}); + +test("master-agent 对话控制 POST 会稳定拒绝非法 modelOverride", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const response = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify({ + modelOverride: 123, + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(response.status, 400); + + const payload = (await response.json()) as { ok: boolean; message: string }; + assert.equal(payload.ok, false); + assert.equal(payload.message, "INVALID_MODEL_OVERRIDE"); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls, null); +}); + +test("master-agent 对话控制 POST 会稳定拒绝 malformed JSON 和空对象", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const headers = { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }; + + const beforeState = await readState(); + const beforeProject = beforeState.projects.find((item) => item.id === "master-agent"); + assert.ok(beforeProject, "expected seeded master-agent project"); + const beforeUpdatedAt = beforeProject.updatedAt; + const beforeThreadMetaUpdatedAt = beforeProject.threadMeta.updatedAt; + + const malformedResponse = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: "{", + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(malformedResponse.status, 400); + assert.equal((await malformedResponse.json()).message, "INVALID_JSON_PAYLOAD"); + + const emptyObjectResponse = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: JSON.stringify({}), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(emptyObjectResponse.status, 400); + assert.equal((await emptyObjectResponse.json()).message, "INVALID_AGENT_CONTROLS_PAYLOAD"); + + const nullResponse = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: "null", + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(nullResponse.status, 400); + assert.equal((await nullResponse.json()).message, "INVALID_AGENT_CONTROLS_PAYLOAD"); + + const arrayResponse = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: "[]", + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(arrayResponse.status, 400); + assert.equal((await arrayResponse.json()).message, "INVALID_AGENT_CONTROLS_PAYLOAD"); + + const primitiveResponse = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers, + body: "1", + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + assert.equal(primitiveResponse.status, 400); + assert.equal((await primitiveResponse.json()).message, "INVALID_AGENT_CONTROLS_PAYLOAD"); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls, null); + + const state = await readState(); + const project = state.projects.find((item) => item.id === "master-agent"); + assert.equal(project?.agentControls, undefined); + assert.equal(project?.updatedAt, beforeUpdatedAt); + assert.equal(project?.threadMeta.updatedAt, beforeThreadMetaUpdatedAt); +}); + +test("master-agent 对话控制 helper 会安全忽略非法 modelOverride 输入", async () => { + await setup(); + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }); + + const beforeState = await readState(); + const beforeProject = beforeState.projects.find((item) => item.id === "master-agent"); + assert.ok(beforeProject, "expected seeded master-agent project"); + const beforeUpdatedAt = beforeProject.updatedAt; + const beforeThreadMetaUpdatedAt = beforeProject.threadMeta.updatedAt; + + await assert.rejects( + () => + updateProjectAgentControls("master-agent", { + modelOverride: { bad: true } as never, + reasoningEffortOverride: "medium", + }), + /INVALID_MODEL_OVERRIDE/, + ); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls?.modelOverride, "gpt-5.4"); + assert.equal(controls?.reasoningEffortOverride, "high"); + + const afterState = await readState(); + const afterProject = afterState.projects.find((item) => item.id === "master-agent"); + assert.equal(afterProject?.updatedAt, beforeUpdatedAt); + assert.equal(afterProject?.threadMeta.updatedAt, beforeThreadMetaUpdatedAt); +}); + +test("master-agent 对话控制 helper 重复提交或重复清空不应刷新更新时间", async () => { + await setup(); + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }); + + const beforeRepeatState = await readState(); + const beforeRepeatProject = beforeRepeatState.projects.find((item) => item.id === "master-agent"); + assert.ok(beforeRepeatProject, "expected seeded master-agent project"); + const beforeRepeatUpdatedAt = beforeRepeatProject.updatedAt; + const beforeRepeatThreadMetaUpdatedAt = beforeRepeatProject.threadMeta.updatedAt; + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }); + + const afterRepeatState = await readState(); + const afterRepeatProject = afterRepeatState.projects.find((item) => item.id === "master-agent"); + assert.equal(afterRepeatProject?.updatedAt, beforeRepeatUpdatedAt); + assert.equal(afterRepeatProject?.threadMeta.updatedAt, beforeRepeatThreadMetaUpdatedAt); + + await updateProjectAgentControls("master-agent", { + modelOverride: undefined, + reasoningEffortOverride: undefined, + }); + + const afterClearState = await readState(); + const afterClearProject = afterClearState.projects.find((item) => item.id === "master-agent"); + const clearedUpdatedAt = afterClearProject?.updatedAt; + const clearedThreadMetaUpdatedAt = afterClearProject?.threadMeta.updatedAt; + + await updateProjectAgentControls("master-agent", { + modelOverride: undefined, + reasoningEffortOverride: undefined, + }); + + const afterRepeatClearState = await readState(); + const afterRepeatClearProject = afterRepeatClearState.projects.find((item) => item.id === "master-agent"); + assert.equal(afterRepeatClearProject?.updatedAt, clearedUpdatedAt); + assert.equal(afterRepeatClearProject?.threadMeta.updatedAt, clearedThreadMetaUpdatedAt); + assert.equal(await getProjectAgentControls("master-agent"), null); +}); + +test("master-agent 对话控制 helper 单字段更新不会清掉另一字段", async () => { + await setup(); + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }); + + await updateProjectAgentControls("master-agent", { + reasoningEffortOverride: "low", + }); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls?.modelOverride, "gpt-5.4"); + assert.equal(controls?.reasoningEffortOverride, "low"); +}); + +test("master-agent 对话控制可清空为未覆盖状态", async () => { + await setup(); + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }); + + await updateProjectAgentControls("master-agent", { + modelOverride: undefined, + reasoningEffortOverride: undefined, + }); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls, null); + + const state = await readState(); + const project = state.projects.find((item) => item.id === "master-agent"); + assert.equal(project?.agentControls, undefined); + + const detail = getProjectDetailView(state, "master-agent"); + assert.equal(detail?.agentControls, null); +}); + +test("GET /agent-controls returns 404 for missing project", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const response = await getAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/missing-project/agent-controls", { + method: "GET", + headers: { + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + }), + { params: Promise.resolve({ projectId: "missing-project" }) }, + ); + + assert.equal(response.status, 404); + assert.equal((await response.json()).message, "PROJECT_NOT_FOUND"); +}); + +test( + "GET /agent-controls returns 404 when master-agent record is missing from state", + { concurrency: false }, + async () => { + await setup(); + + const script = String.raw` + import { mkdtemp, writeFile } from "node:fs/promises"; + import os from "node:os"; + import path from "node:path"; + import { NextRequest } from "next/server"; + + (async () => { + const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-missing-")); + process.env.BOSS_RUNTIME_ROOT = runtimeRoot; + process.env.BOSS_STATE_FILE = path.join(runtimeRoot, "boss-state.json"); + + const dataModule = await import("./src/lib/boss-data.ts"); + const authModule = await import("./src/lib/boss-auth.ts"); + const routeModule = await import("./src/app/api/v1/projects/[projectId]/agent-controls/route.ts"); + const data = dataModule.default ?? dataModule; + const auth = authModule.default ?? authModule; + const route = routeModule.default ?? routeModule; + + const session = await data.createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const state = await data.readState(); + state.projects = state.projects.filter((item) => item.id !== "master-agent"); + await writeFile(process.env.BOSS_STATE_FILE!, JSON.stringify(state, null, 2), "utf8"); + + if (await data.hasPersistedProject("master-agent")) { + throw new Error("expected raw state to miss master-agent"); + } + + const response = await route.GET( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "GET", + headers: { + cookie: auth.AUTH_SESSION_COOKIE + "=" + session.sessionToken, + }, + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + const text = await response.text(); + if (response.status !== 404) { + throw new Error("expected 404, got " + response.status + ": " + text); + } + if (!text.includes("PROJECT_NOT_FOUND")) { + throw new Error("expected PROJECT_NOT_FOUND, got " + text); + } + console.log(text); + })().catch((error) => { + console.error(error); + process.exit(1); + }); + `; + + const tsxBin = path.resolve("node_modules/.bin/tsx"); + const { stdout } = await execFile(tsxBin, ["--eval", script], { + cwd: process.cwd(), + env: { ...process.env }, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + + assert.match(stdout, /PROJECT_NOT_FOUND/); + }, +); + +test("GET /agent-controls 在未显式设置 BOSS_STATE_FILE 时仍可正常读取 controls", async () => { + const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-master-agent-default-state-")); + const childEnv = { ...process.env }; + delete childEnv.BOSS_STATE_FILE; + const script = String.raw` + import { NextRequest } from "next/server"; + + (async () => { + const runtimeRoot = process.env.BOSS_RUNTIME_ROOT; + if (!runtimeRoot) { + throw new Error("missing BOSS_RUNTIME_ROOT"); + } + delete process.env.BOSS_STATE_FILE; + + const dataModule = await import("./src/lib/boss-data.ts"); + const authModule = await import("./src/lib/boss-auth.ts"); + const routeModule = await import("./src/app/api/v1/projects/[projectId]/agent-controls/route.ts"); + const data = dataModule.default ?? dataModule; + const auth = authModule.default ?? authModule; + const route = routeModule.default ?? routeModule; + + await data.updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "medium", + }); + + const session = await data.createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const response = await route.GET( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "GET", + headers: { + cookie: auth.AUTH_SESSION_COOKIE + "=" + session.sessionToken, + }, + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + const payload = await response.json(); + if (response.status !== 200) { + throw new Error("expected 200, got " + response.status + ": " + JSON.stringify(payload)); + } + if (!payload.ok || payload.controls?.modelOverride !== "gpt-5.4" || payload.controls?.reasoningEffortOverride !== "medium") { + throw new Error("unexpected payload: " + JSON.stringify(payload)); + } + console.log("OK"); + })().catch((error) => { + console.error(error); + process.exit(1); + }); + `; + + const tsxBin = path.resolve("node_modules/.bin/tsx"); + const { stdout } = await execFile(tsxBin, ["--eval", script], { + cwd: process.cwd(), + env: { ...childEnv, BOSS_RUNTIME_ROOT: runtimeRoot }, + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); + + assert.match(stdout, /OK/); +}); + +test("GET /agent-controls rejects ordinary projects", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + const response = await getAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/boss-console/agent-controls", { + method: "GET", + headers: { + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + }), + { params: Promise.resolve({ projectId: "boss-console" }) }, + ); + + assert.equal(response.status, 404); + assert.equal((await response.json()).message, "PROJECT_NOT_FOUND"); +}); + +test("POST /agent-controls rejects unknown-key payload and preserves controls", async () => { + await setup(); + + const session = await createAuthSession({ + account: "17600003315", + role: "highest_admin", + displayName: "Boss 超级管理员", + loginMethod: "password", + }); + + await updateProjectAgentControls("master-agent", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "high", + }); + const beforeState = await readState(); + const beforeProject = beforeState.projects.find((item) => item.id === "master-agent"); + assert.ok(beforeProject, "expected seeded master-agent project"); + const beforeUpdatedAt = beforeProject.updatedAt; + + const response = await postAgentControlsRoute( + new NextRequest("http://127.0.0.1:3000/api/v1/projects/master-agent/agent-controls", { + method: "POST", + headers: { + "content-type": "application/json", + cookie: `${AUTH_SESSION_COOKIE}=${session.sessionToken}`, + }, + body: JSON.stringify({ + unexpectedKey: "value", + }), + }), + { params: Promise.resolve({ projectId: "master-agent" }) }, + ); + + assert.equal(response.status, 400); + assert.equal((await response.json()).message, "INVALID_AGENT_CONTROLS_PAYLOAD"); + + const controls = await getProjectAgentControls("master-agent"); + assert.equal(controls?.modelOverride, "gpt-5.4"); + assert.equal(controls?.reasoningEffortOverride, "high"); + + const afterState = await readState(); + const afterProject = afterState.projects.find((item) => item.id === "master-agent"); + assert.equal(afterProject?.updatedAt, beforeUpdatedAt); +}); + +test("master-agent controls helper 不会写入普通项目", async () => { + await setup(); + + await assert.rejects( + () => + updateProjectAgentControls("boss-console", { + modelOverride: "gpt-5.4", + reasoningEffortOverride: "low", + }), + /MASTER_AGENT_CONTROLS_SCOPE_RESTRICTED/, + ); + + const state = await readState(); + const project = state.projects.find((item) => item.id === "boss-console"); + assert.equal(project?.agentControls, undefined); + assert.equal(await getProjectAgentControls("boss-console"), null); +});