28 Commits
Author SHA1 Message Date
jiang c6efccb88a fix(chat): only expose final agent response
Generic Container CI/CD / test-build-publish (push) Successful in 1m4s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 1m4s
2026-08-24 18:30:24 +08:00
jiang 11ebf428bb merge: integrate tjwater-cli into main
Merge PR #1 after CLI, contract, test, and container gates passed.
2026-08-18 17:56:44 +08:00
jiang 18e8b25f48 fix(agent): bound CLI subprocess execution 2026-08-18 17:00:23 +08:00
jiang 9aa5a96e60 merge(agent): integrate main into tjwater-cli 2026-08-18 16:42:15 +08:00
jiang a5f6474be5 fix(health): align Agent readiness contract
Generic Container CI/CD / test-build-publish (push) Successful in 35s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 35s
2026-08-11 11:20:40 +08:00
jiang b49290bd91 fix(container): isolate Agent runtime state
Generic Container CI/CD / test-build-publish (push) Successful in 3m1s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 3m1s
2026-08-11 10:10:30 +08:00
jiang 31fdb36e48 ci: activate Agent v2 deployment workflow 2026-08-11 09:29:30 +08:00
TJWater CI 0a64de89bb ci: replace Agent webhook workflow with v2 deployment
Generic Container CI/CD / test-build-publish (push) Successful in 34s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 34s
2026-08-11 09:26:46 +08:00
TJWater CI 2f267af7a3 revert: remove unused Agent PostgreSQL persistence 2026-08-07 18:06:08 +08:00
TJWater CI 649af949c5 feat(deploy): include Agent storage migration script in image 2026-08-07 18:00:55 +08:00
jiang c565e89d60 ci(agent): 添加新版发布与传输验证工作流 2026-08-07 18:00:54 +08:00
TJWater CI 9c9e31c570 fix(ci): provide dependencies to Agent build test stage 2026-08-07 17:58:49 +08:00
TJWater CI c0c54e238d fix(ci): include package manifest in Agent build test stage 2026-08-07 17:54:04 +08:00
TJWater CI 99f5a0b823 ci: run Agent Docker build test target 2026-08-07 17:52:08 +08:00
TJWater CI 4a9681c148 ci: use internal offline build cache 2026-08-07 17:35:56 +08:00
TJWater CI 8530793882 ci: add reusable container deployment workflow 2026-08-07 17:23:21 +08:00
jiang cb3aa3a150 ci: add Dev deployment transport canary 2026-08-07 17:15:27 +08:00
jiang 4cbeca4e09 fix(permissions): require approval for backend queries 2026-08-06 20:03:20 +08:00
jiang f66b9c3e9d fix(agent): disable unobservable task subagents 2026-08-06 19:24:03 +08:00
jiang b19af8846a feat(agent): 完善权限与结果引用安全 2026-08-06 18:41:52 +08:00
jiang a5e91ac2b8 test(cli): remove Python parity dependency 2026-08-06 15:58:47 +08:00
jiang 258f4996eb feat(agent): add credential refresh and unify learning tools 2026-08-06 10:16:50 +08:00
jiang 2dc37e3fd8 fix(agent): preserve runtime request context
OpenCode tools run in a child service, so they cannot read the Agent process-local session map. Hydrate a sanitized context through the authenticated internal bridge and centralize backend project headers to prevent context loss across both boundaries.
2026-08-05 18:39:09 +08:00
jiang a53839e157 fix(cli): map timeseries element types for backend 2026-08-05 18:39:00 +08:00
jiang 1407dd3bbe fix(opencode): restore stable v1 runtime 2026-08-05 17:59:35 +08:00
jiang 764a1f4e82 feat(opencode): migrate agent runtime to v2 2026-08-04 16:56:04 +08:00
jiang 07016451d6 fix(agent): complete opencode warmup before serving
The previous fire-and-forget startup only created the SDK client, leaving the first chat to await project and tool initialization. Warm the real session/tool path and gate port listening on completion.
2026-08-04 15:25:00 +08:00
jiang 5ac50bfeaa 切换到使用pg数据库 2026-05-28 18:22:39 +08:00
48 changed files with 2879 additions and 1072 deletions
+4
View File
@@ -1,7 +1,11 @@
.git .git
node_modules node_modules
.opencode/node_modules .opencode/node_modules
.env
.env.*
.local.env .local.env
data/
logs/
dist dist
.vscode .vscode
*.log *.log
+13 -211
View File
@@ -1,221 +1,23 @@
name: Agent CI/CD name: Agent CI/CD v2
on: on:
push: push:
tags: tags:
- "v*" - "v*"
- "latest"
workflow_dispatch: {} workflow_dispatch: {}
jobs: jobs:
docker-image: build-test-publish-and-deploy:
runs-on: ubuntu-22.04 uses: OrgTJWater/ci-templates/.gitea/workflows/container-cd.yml@main
if: startsWith(github.ref, 'refs/tags/') with:
permissions: image_name: gitea.waternetwork.cn/orgtjwater/tjwateragent
contents: read dockerfile: Dockerfile
defaults: build_context: .
run: cache_image: gitea.waternetwork.cn/orgtjwater/tjwateragent:ci-cache
shell: bash test_target: test
deploy_service: agent
steps: deploy_host: 192.168.1.114
- name: Setup tools secrets:
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq jq
jq --version
- name: Checkout code
env:
SERVER_URL: ${{ github.server_url }}
REPOSITORY: ${{ github.repository }}
COMMIT_SHA: ${{ github.sha }}
GIT_USERNAME: ${{ github.actor }}
GIT_TOKEN: ${{ github.token }}
run: |
case "$SERVER_URL" in
http://*)
AUTH_SERVER_URL="http://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#http://}"
;;
https://*)
AUTH_SERVER_URL="https://${GIT_USERNAME}:${GIT_TOKEN}@${SERVER_URL#https://}"
;;
*)
AUTH_SERVER_URL="$SERVER_URL"
;;
esac
if [ ! -d .git ]; then
git init .
fi
if git remote get-url origin >/dev/null 2>&1; then
git remote set-url origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
else
git remote add origin "${AUTH_SERVER_URL}/${REPOSITORY}.git"
fi
git fetch --depth=1 origin "$COMMIT_SHA"
git checkout --force --detach FETCH_HEAD
git clean -ffdx
- name: Normalize image metadata
env:
RAW_REGISTRY_HOST: ${{ vars.REGISTRY_HOST }}
RAW_REPOSITORY: ${{ github.repository }}
RAW_REF: ${{ github.ref }}
RAW_REF_NAME: ${{ github.ref_name }}
run: |
RAW_REGISTRY_HOST="$(printf '%s' "${RAW_REGISTRY_HOST}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')"
if [ -z "${RAW_REGISTRY_HOST}" ]; then
echo "Missing required repository variable: REGISTRY_HOST"
exit 1
fi
REGISTRY_HOST="${RAW_REGISTRY_HOST#http://}"
REGISTRY_HOST="${REGISTRY_HOST#https://}"
REGISTRY_HOST="${REGISTRY_HOST%/}"
if [ -z "${REGISTRY_HOST}" ]; then
echo "Repository variable REGISTRY_HOST resolves to an empty host"
exit 1
fi
REPOSITORY_PATH="${RAW_REPOSITORY#/}"
IMAGE_REPOSITORY_PATH="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')"
IMAGE_NAME="${REGISTRY_HOST}/${IMAGE_REPOSITORY_PATH}"
IMAGE_TAG="${RAW_REF_NAME}"
{
echo "REGISTRY_HOST=${REGISTRY_HOST}"
echo "REPOSITORY_PATH=${REPOSITORY_PATH}"
echo "IMAGE_REPOSITORY_PATH=${IMAGE_REPOSITORY_PATH}"
echo "IMAGE_NAME=${IMAGE_NAME}"
echo "IMAGE_TAG=${IMAGE_TAG}"
echo "IMAGE_REF=${IMAGE_NAME}:${IMAGE_TAG}"
} >> "$GITHUB_ENV"
- name: Login to Gitea Container Registry
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: | DEV_DEPLOY_SSH_KEY: ${{ secrets.DEV_DEPLOY_SSH_KEY }}
if [ -z "${REGISTRY_HOST:-}" ]; then
echo "Missing resolved environment value: REGISTRY_HOST"
echo "The previous step should write REGISTRY_HOST into GITHUB_ENV."
exit 1
fi
if [ -z "${REGISTRY_USERNAME}" ]; then
echo "Missing required repository secret: REGISTRY_USERNAME"
exit 1
fi
if [ -z "${REGISTRY_PASSWORD}" ]; then
echo "Missing required repository secret: REGISTRY_PASSWORD"
exit 1
fi
echo "Logging into registry host: ${REGISTRY_HOST}"
echo "${REGISTRY_PASSWORD}" | docker login "$REGISTRY_HOST" \
--username "${REGISTRY_USERNAME}" \
--password-stdin
- name: Build and Push Image
run: |
if [ -z "${IMAGE_NAME:-}" ] || [ -z "${IMAGE_TAG:-}" ]; then
echo "Missing resolved image metadata: IMAGE_NAME or IMAGE_TAG"
exit 1
fi
push_with_retry() {
image_ref="$1"
attempt=1
max_attempts=3
while [ "$attempt" -le "$max_attempts" ]; do
if docker push "$image_ref"; then
return 0
fi
if [ "$attempt" -eq "$max_attempts" ]; then
return 1
fi
echo "Push failed for $image_ref (attempt $attempt/$max_attempts); retrying in 10s..."
attempt=$((attempt + 1))
sleep 10
done
}
if [ "${IMAGE_TAG}" = "latest" ]; then
docker build \
--network=host \
-f ./Dockerfile \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:latest"
else
docker build \
--network=host \
-f ./Dockerfile \
-t "${IMAGE_NAME}:${IMAGE_TAG}" \
-t "${IMAGE_NAME}:latest" \
.
push_with_retry "${IMAGE_NAME}:${IMAGE_TAG}"
push_with_retry "${IMAGE_NAME}:latest"
fi
- name: Notify Deploy Server
run: |
post_deploy_webhook() {
label="$1"
payload="$2"
webhook_url="${{ vars.DEPLOY_WEBHOOK_URL }}"
token="${{ secrets.DEPLOY_WEBHOOK_TOKEN }}"
# Trim whitespace
webhook_url=$(echo "$webhook_url" | xargs)
echo "[$label] Calling webhook: $webhook_url"
http_code=$(curl -sS -D /tmp/deploy_headers.txt -o /tmp/deploy_response.txt -w "%{http_code}" -X POST "$webhook_url" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $token" \
-d "$payload")
echo "[$label] webhook HTTP status: ${http_code}"
if [ "$http_code" -ge 200 ] && [ "$http_code" -lt 300 ]; then
return 0
fi
echo "[$label] response headers:"
cat /tmp/deploy_headers.txt
echo "[$label] response body:"
cat /tmp/deploy_response.txt
return 1
}
PRIMARY_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${REPOSITORY_PATH}\"}"
FALLBACK_PAYLOAD="{\"image\":\"${IMAGE_REF}\",\"tag\":\"${IMAGE_TAG}\",\"repo\":\"${IMAGE_REPOSITORY_PATH}\"}"
echo "Deploy webhook target: ${{ vars.DEPLOY_WEBHOOK_URL }}"
echo "Deploy payload(primary): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${REPOSITORY_PATH}"
if post_deploy_webhook "primary" "$PRIMARY_PAYLOAD"; then
exit 0
fi
echo "Primary webhook request failed, retrying with lowercase repo path..."
echo "Deploy payload(fallback): image=${IMAGE_REF}, tag=${IMAGE_TAG}, repo=${IMAGE_REPOSITORY_PATH}"
if post_deploy_webhook "fallback" "$FALLBACK_PAYLOAD"; then
exit 0
fi
echo "Deploy webhook failed after primary and fallback attempts."
exit 1
deploy-fallback-log:
runs-on: ubuntu-22.04
needs: docker-image
if: failure()
steps:
- name: Deployment not triggered
run: echo "Image build/push failed, deployment webhook was not called."
+6 -2
View File
@@ -2,10 +2,14 @@
description: TJWater Agent,用于供水网络分析和操作员工作流 description: TJWater Agent,用于供水网络分析和操作员工作流
mode: primary mode: primary
model: deepseek/deepseek-v4-flash model: deepseek/deepseek-v4-flash
temperature: 0.2
--- ---
你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。 你是 TJWater 供水管网分析 Agent,运用水力专业知识,回复用户时使用简体中文,内容要求简洁准确。
## 回复要求
- 工具执行期间不输出过程说明,全部完成后只回复最终结果
- 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步
## 工作流生命周期 ## 工作流生命周期
Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的: Skills 树是**动态生长的**——工作流不是预置的,而是从实际任务中沉淀出来的:
@@ -44,7 +48,7 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际
3. 大结果集禁止完整读取,优先采样/截断/按字段读取 3. 大结果集禁止完整读取,优先采样/截断/按字段读取
4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout 4. 避免直接用 `Read``cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout
5. 无可用数据时不得编造结果 5. 无可用数据时不得编造结果
6. 尽量不使用 `task` 子代理,避免无法观测过程进行人为干预 6. 禁止使用 `task` 子代理;当前前端无法观测和干预子代理的具体工作过程
## 工作流沉淀(skill_manager ## 工作流沉淀(skill_manager
+8 -4
View File
@@ -4,7 +4,7 @@
"workspaces": { "workspaces": {
"": { "": {
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "^1.16.2", "@opencode-ai/plugin": "1.18.13",
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
@@ -13,6 +13,8 @@
}, },
}, },
"packages": { "packages": {
"@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="], "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
"@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
@@ -25,9 +27,9 @@
"@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="], "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.16.2", "", { "dependencies": { "@opencode-ai/sdk": "1.16.2", "effect": "4.0.0-beta.74", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.2", "@opentui/keymap": ">=0.3.2", "@opentui/solid": ">=0.3.2" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-FaZhVXrbz93xsdGLCtarRDTeqFt8AkLfh8B34tFBj6G4HXVmKSgBwVXmtELKKC+08xMtawBC9hshiMbXryv6cg=="], "@opencode-ai/plugin": ["@opencode-ai/plugin@1.18.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.18.13", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.4.5", "@opentui/keymap": ">=0.4.5", "@opentui/solid": ">=0.4.5" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-2H9YT80M1PYElpG+lmd/9kGqsNouiJIBCUhLblmgFwoSrB4wyahgkCS6NcFQR/AYXNH4I1Yd3lmQcVaEPu1qNg=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
@@ -37,7 +39,7 @@
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"effect": ["effect@4.0.0-beta.74", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-Yx+Kh12U+i2FmjwEfKs+ePFmpMd43RPD1oGqc/VraSS9bYzvF0Ff3PojwEFEVEewp8xc92Uxu28gTspU4qyvHA=="], "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
"fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="], "fast-check": ["fast-check@4.8.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-GOJ158CUMnN6cSahsv4+ExARvIDuzzinFjkp0E9WtiBa5zcVeLozVkWaE4IzFcc+Y48Wp1EDlUZsXRyAztQcSg=="],
@@ -47,6 +49,8 @@
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="], "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
"msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="], "msgpackr": ["msgpackr@2.0.2", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-c5hYOXFbP79Slh6Dzd2wzk+jnV7mX1UxfMYtilnY1NmalXPqG8DGb5cYCMBrW4AsH3zekBBZd4QrKz9NhtvYLQ=="],
+1 -1
View File
@@ -4,7 +4,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/plugin": "^1.16.2" "@opencode-ai/plugin": "1.18.13"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^24.7.2", "@types/node": "^24.7.2",
+19 -122
View File
@@ -1,12 +1,8 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { MemoryStore } from "../../src/memory/store.js";
import {
getRuntimeSessionContext,
setRuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
const memoryStore = new MemoryStore(); const internalBaseUrl =
const initializePromise = memoryStore.initialize(); process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
export default tool({ export default tool({
description: description:
@@ -26,130 +22,31 @@ export default tool({
content: tool.schema content: tool.schema
.string() .string()
.optional() .optional()
.describe( .describe("The durable fact or preference to remember, written as one concise sentence."),
"The durable fact or preference to remember, written as one concise sentence.",
),
target_id: tool.schema target_id: tool.schema
.string() .string()
.optional() .optional()
.describe("Stable memory entry id used by replace/remove."), .describe("Stable memory entry id used by replace/remove."),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; const response = await fetch(
const sessionContext = getRuntimeSessionContext(context.sessionID); `${internalBaseUrl}/internal/tools/memory-manager`,
if (!sessionContext) {
throw new Error(`session context not found for ${context.sessionID}`);
}
const scope =
args.scope === "user"
? "user"
: args.scope === "workspace"
? "workspace"
: null;
if (!scope) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: `unsupported scope: ${args.scope}; use exact keyword 'user' or 'workspace'`,
});
}
if (sessionContext.allowLearningWrite === false && args.action !== "list") {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "memory writes are disabled for this session",
});
}
const scopeKey =
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
if (args.action === "list") {
const readScopes = {
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
};
setRuntimeSessionContext({
...sessionContext,
memoryListReadScopes: readScopes,
});
return JSON.stringify({
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items: await memoryStore.list(scope, scopeKey),
target: scope,
});
}
if (args.action === "add") {
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: `must list ${scope} memory and review existing entries before add`,
target: scope,
});
}
const result = await memoryStore.upsert(scope, scopeKey, {
content: args.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
});
if (!result.entry) {
return JSON.stringify({
ok: true,
kind: "memory",
decision: "rejected",
detail: "content rejected by persistence policy",
});
}
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "deduped",
detail: result.detail,
entry: result.entry,
target: scope,
});
}
if (args.action === "replace") {
const result = await memoryStore.replace(
scope,
scopeKey,
args.target_id ?? "",
{ {
content: args.content ?? "", method: "POST",
sessionId: sessionContext.clientSessionId, headers: {
source: "tool", "Content-Type": "application/json",
traceId: sessionContext.traceId, "x-agent-internal-token": internalToken,
},
body: JSON.stringify({
...args,
session_id: context.sessionID,
}),
}, },
); );
return JSON.stringify({ const text = await response.text();
ok: true, if (!response.ok) {
kind: "memory", throw new Error(text);
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
} }
return text;
const result = await memoryStore.remove(
scope,
scopeKey,
args.target_id ?? "",
);
return JSON.stringify({
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
});
}, },
}); });
+1 -1
View File
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin";
export default tool({ export default tool({
description: description:
"在前端地图上对 junctions 图层应用分区渲染。使用前必须完成两步:① 准备数据结构(JSON 文件,结构为 { node_area_map: Record<string, string>, area_ids?: string[], area_colors?: Record<string, string> },其中 node_area_map 的 key 是 junction/node idvalue 是 area id);② 调用 store_render_ref 将 JSON 文件存储到受控路径,获取 render_ref(格式为 res-...);③ 将 render_ref 传入本工具完成前端渲染。注意:不要先把 ref 内容完整读出再传给前端,也不要直接传本地文件路径。", "在前端地图上对 junctions 图层应用分区渲染。先把包装格式 { metadata, location: { file_path }, data: { node_area_map, area_ids?, area_colors? } } 写入 RESULT_REF_IMPORT_DIRlocation.file_path 必须等于文件绝对路径;再调用 store_render_ref 获得 res-... 引用,最后把引用传入本工具。不要读取并转传完整 ref 内容,也不要直接传本地文件路径。",
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
+26 -117
View File
@@ -1,25 +1,10 @@
import { tool } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin";
import { SkillStore } from "../../src/skills/store.js"; const internalBaseUrl =
import { process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
getRuntimeSessionContext, const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
type RuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
type ToolContextReader = { export default tool({
read(sessionId: string): RuntimeSessionContext | null;
};
const runtimeContextReader: ToolContextReader = {
read: getRuntimeSessionContext,
};
export const createSkillManagerTool = (
skillStore = new SkillStore(),
toolContextStore: ToolContextReader = runtimeContextReader,
initializePromise: Promise<unknown> = Promise.resolve(),
) =>
tool({
description: description:
"维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。", "维护已验证、可复用、非敏感的 workflow 或方法模式。支持 list、write_skill、remove_skill、append_pattern、remove_pattern、write_reference、remove_reference、write_script、remove_script。",
args: { args: {
@@ -38,18 +23,13 @@ export const createSkillManagerTool = (
.describe("Skill maintenance operation."), .describe("Skill maintenance operation."),
reason: tool.schema reason: tool.schema
.string() .string()
.describe( .describe("Why this skill maintenance action is justified for future reuse."),
"Why this skill maintenance action is justified for future reuse.",
),
skill_path: tool.schema skill_path: tool.schema
.string() .string()
.describe( .describe(
"Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.", "Target skill directory path relative to .opencode/skills. Use 'workflow' for the workflow index, or '__root__' for the root skills index.",
), ),
pattern: tool.schema pattern: tool.schema.string().optional().describe("Pattern text used by append_pattern."),
.string()
.optional()
.describe("Pattern text used by append_pattern."),
target_id: tool.schema target_id: tool.schema
.string() .string()
.optional() .optional()
@@ -57,102 +37,31 @@ export const createSkillManagerTool = (
file_path: tool.schema file_path: tool.schema
.string() .string()
.optional() .optional()
.describe( .describe("Asset file path. For references use references/*.md; for scripts use scripts/*.py."),
"Asset file path. For references use references/*.md; for scripts use scripts/*.py.",
),
content: tool.schema content: tool.schema
.string() .string()
.optional() .optional()
.describe( .describe("Content used by write_skill, write_reference, or write_script."),
"Content used by write_skill, write_reference, or write_script.",
),
}, },
async execute(args, context) { async execute(args, context) {
await initializePromise; const response = await fetch(
const sessionContext = toolContextStore.read(context.sessionID); `${internalBaseUrl}/internal/tools/skill-manager`,
if (!sessionContext) { {
throw new Error(`session context not found for ${context.sessionID}`); method: "POST",
} headers: {
if ( "Content-Type": "application/json",
sessionContext.allowLearningWrite === false && "x-agent-internal-token": internalToken,
args.action !== "list" },
) { body: JSON.stringify({
return JSON.stringify({ ...args,
ok: true, session_id: context.sessionID,
kind: "skill", }),
decision: "rejected", },
detail: "skill writes are disabled for this session",
});
}
if (args.action === "list") {
const result = await skillStore.list(args.skill_path);
if (!result) {
return JSON.stringify({
ok: true,
kind: "skill",
decision: "rejected",
detail:
"invalid skill_path; expected a relative path under .opencode/skills",
});
}
return JSON.stringify({
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
references: result.references,
scripts: result.scripts,
skill_path: result.skillPath,
target: result.target,
patterns: result.patterns,
});
}
const result =
args.action === "write_skill"
? await skillStore.writeSkill(args.skill_path, args.content ?? "")
: args.action === "remove_skill"
? await skillStore.removeSkill(args.skill_path)
: args.action === "append_pattern"
? await skillStore.appendPattern(
args.skill_path,
args.pattern ?? "",
)
: args.action === "remove_pattern"
? await skillStore.removePattern(
args.skill_path,
args.target_id ?? "",
)
: args.action === "write_reference"
? await skillStore.writeReference(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: args.action === "remove_reference"
? await skillStore.removeReference(
args.skill_path,
args.file_path ?? "",
)
: args.action === "write_script"
? await skillStore.writeScript(
args.skill_path,
args.file_path ?? "",
args.content ?? "",
)
: await skillStore.removeScript(
args.skill_path,
args.file_path ?? "",
); );
const text = await response.text();
return JSON.stringify({ if (!response.ok) {
ok: true, throw new Error(text);
kind: "skill", }
decision: result.changed ? "accepted" : "rejected", return text;
detail: result.detail,
target: result.target,
});
}, },
}); });
export default createSkillManagerTool();
+4 -2
View File
@@ -3,10 +3,12 @@ import { tool } from "@opencode-ai/plugin";
const internalBaseUrl = const internalBaseUrl =
process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787";
const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? "";
const importDirectory =
process.env.RESULT_REF_IMPORT_DIR ?? "./data/result-imports";
export default tool({ export default tool({
description: description:
"将本地 JSON 渲染数据文件存储到受控路径,返回可供 render_junctions 使用的 render_refres-...)。前置步骤:先准备好符合 render_junctions 数据结构的 JSON 文件 { node_area_map, area_ids?, area_colors? },写入本地路径后再调用本工具传入该路径,获取 render_ref 后传给 render_junctions 完成前端渲染。", `导入 ${importDirectory} 下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受该目录内的真实文件,不接受目录外路径或指向目录外的符号链接。`,
args: { args: {
reason: tool.schema reason: tool.schema
.string() .string()
@@ -16,7 +18,7 @@ export default tool({
file_path: tool.schema file_path: tool.schema
.string() .string()
.describe( .describe(
"本地 JSON 文件绝对路径,内容为 render_junctions 所需的数据结构 { node_area_map, area_ids?, area_colors? }。", `位于 ${importDirectory} 内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 datadata 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。`,
), ),
}, },
async execute(args, context) { async execute(args, context) {
+1 -1
View File
@@ -14,7 +14,7 @@ export default tool({
command: tool.schema command: tool.schema
.string() .string()
.describe( .describe(
"tjwater-cli 子命令,不含二进制路径。示例:'project list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'", "tjwater-cli 子命令,不含二进制路径。示例:'data scheme list'、'data timeseries realtime links --start-time 2025-01-01T00:00:00+08:00 --end-time 2025-01-01T01:00:00+08:00'",
), ),
timeout: tool.schema timeout: tool.schema
.number() .number()
+16 -10
View File
@@ -1,4 +1,4 @@
FROM smanx/opencode:latest AS base FROM smanx/opencode:1.18.13@sha256:b976acda21efffacd44abd7847dac7d646910dbaa477d1877e2881b39cf22a91 AS base
USER root USER root
ARG UBUNTU_APT_MIRROR= ARG UBUNTU_APT_MIRROR=
ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple ARG PYPI_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
@@ -58,32 +58,38 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./
COPY tsconfig.json opencode.json README.md .gitignore ./ COPY tsconfig.json opencode.json README.md .gitignore ./
COPY src ./src COPY src ./src
COPY cli ./cli COPY cli ./cli
COPY .opencode ./.opencode COPY .opencode ./.opencode
RUN bun run check RUN bun run check
FROM base AS runner FROM build AS test
RUN apt-get update && apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*
COPY contracts ./contracts
COPY node-tests ./node-tests
COPY scripts ./scripts
COPY tests ./tests
RUN bun run test:ci
FROM build AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=8787 ENV PORT=8787
ENV OPENCODE_HOST=127.0.0.1
ENV OPENCODE_HOSTNAME=127.0.0.1
ENV TJWATER_CLI_PATH=./cli/tjwater-cli ENV TJWATER_CLI_PATH=./cli/tjwater-cli
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./
COPY tsconfig.json opencode.json .gitignore ./
COPY src ./src
COPY .opencode ./.opencode
COPY cli ./cli
COPY entrypoint.sh /entrypoint.sh COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh ./cli/tjwater-cli RUN chmod +x /entrypoint.sh ./cli/tjwater-cli
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
EXPOSE 8787 EXPOSE 8787
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \
CMD curl --fail --silent --show-error "http://127.0.0.1:${PORT}/health" >/dev/null || exit 1
CMD ["bun", "src/server.ts"] CMD ["bun", "src/server.ts"]
+23 -8
View File
@@ -5,7 +5,7 @@
## 主要能力 ## 主要能力
- 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。 - 提供 `POST /api/v1/agent/sessions/{session_id}/runs` SSE 聊天接口。
- 支持 embedded OpenCode 运行时,也可连接外部 OpenCode server - 以内嵌模式启动并预热 OpenCode 运行时。
- 管理前端 `session_id` 与 OpenCode session 的映射。 - 管理前端 `session_id` 与 OpenCode session 的映射。
- 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。 - 在服务端保存当前会话的用户 token、项目、network 和 trace 上下文。
- 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。 - 通过 `.opencode/tools` 和 MCP 工具驱动地图定位、图表、SCADA、历史数据和业务 API 调用。
@@ -20,6 +20,7 @@ src/chat/ 聊天流和 SSE 事件适配
src/runtime/ OpenCode 运行时管理 src/runtime/ OpenCode 运行时管理
src/session/ 会话映射和运行上下文 src/session/ 会话映射和运行上下文
src/mcp/ MCP 服务与工具桥接 src/mcp/ MCP 服务与工具桥接
cli/ Agent 使用的 TypeScript 后端 API CLI
.opencode/agents/ Agent prompt 和模型行为配置 .opencode/agents/ Agent prompt 和模型行为配置
.opencode/tools/ OpenCode 自定义工具 .opencode/tools/ OpenCode 自定义工具
.opencode/skills/ 可复用分析工作流 .opencode/skills/ 可复用分析工作流
@@ -39,6 +40,10 @@ bun run dev
`bun install` 会通过 `postinstall` 安装 `.opencode` 子目录依赖。`bun run dev` 以 watch 模式启动 `src/server.ts`,修改 `src/**``.opencode/**``opencode.json``.local.env` 后会自动重启。 `bun install` 会通过 `postinstall` 安装 `.opencode` 子目录依赖。`bun run dev` 以 watch 模式启动 `src/server.ts`,修改 `src/**``.opencode/**``opencode.json``.local.env` 后会自动重启。
`cli/tjwater-cli` 是当前唯一的 TJWater 业务 CLI 入口,由 Bun 直接执行
`cli/tjwater-cli.ts``cli/src/` 源码,并随 Agent 镜像一起交付,不需要
Python 或 PyInstaller 构建步骤。
## 常用命令 ## 常用命令
```bash ```bash
@@ -60,23 +65,33 @@ docker build -t tjwater-agent:local .
## 运行模式 ## 运行模式
Embedded 模式由服务进程拉起本机 OpenCode: 当前运行时使用 OpenCode 稳定版 1.x CLI,并通过稳定版 SDK 的 `@opencode-ai/sdk/v2` HTTP 客户端访问运行时;这与 `opencode2``@opencode-ai/client` 的 2.0 beta 运行时不同。Embedded 模式由服务进程拉起本机 OpenCode:
```bash ```bash
OPENCODE_MODE=embedded OPENCODE_MODE=embedded
TJWATER_API_BASE_URL=http://127.0.0.1:8000 TJWATER_API_BASE_URL=http://127.0.0.1:8000
``` ```
Client 模式连接外部 OpenCode server 当前仅支持 Embedded 模式,不支持连接外部 OpenCode server
```bash ## 认证续期与学习工具
OPENCODE_MODE=client
OPENCODE_CLIENT_BASE_URL=http://127.0.0.1:4096 后端工具调用遇到即将过期的 access token 或首次 `401` 时,Agent 会通过当前 SSE 流发送 `credential_refresh_required`。前端使用服务端保存的 Keycloak refresh token 强制换取新 access token,再调用 `POST /api/v1/agent/sessions/{session_id}/credential-refreshes` 唤醒原工具调用。等待上限为 30 秒,同一会话的并发请求合并为一次续期,原调用最多重试一次;`403` 不触发续期。
TJWATER_API_BASE_URL=http://127.0.0.1:8000
``` `memory_manager``skill_manager` 在 OpenCode 侧只保留内部 HTTP 桥,读取会话上下文和持久化数据的逻辑统一在 Agent 主进程中执行。长期记忆、自动学习和显式工具写入因此共享同一组 `MemoryStore``SkillStore` 和运行时会话上下文。
本地可使用 `.local.env` 保存开发配置;系统环境变量优先级更高。 本地可使用 `.local.env` 保存开发配置;系统环境变量优先级更高。
服务会在 HTTP 端口开始监听前完成 OpenCode 健康检查、临时会话创建和工具目录加载。`GET /health` 返回 `ready: true``warmed_up: true` 时,表示冷启动预热已经完成。开发环境会输出各预热阶段的耗时。
`opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。
前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具,其他请求仍需确认;“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。外部目录以及 `.env``data/``logs/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则。
`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata``location.file_path``data`,且真实路径不能越出导入目录;单文件默认上限为 64 MiB,成功导入后源包装文件会被删除。
## 配置与安全 ## 配置与安全
不要提交 `.env``.local.env``data/``logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。 不要提交 `.env``.local.env``data/``logs/`、会话记录、模型输出、访问令牌或 `node_modules/`。部署凭据、镜像仓库账号和 webhook 地址应放在 Gitea secrets 或部署环境变量中。
+2 -2
View File
@@ -5,7 +5,7 @@
"": { "": {
"name": "tjwater-agent", "name": "tjwater-agent",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.16.2", "@opencode-ai/sdk": "1.18.13",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
@@ -26,7 +26,7 @@
"packages": { "packages": {
"@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="], "@asteasolutions/zod-to-openapi": ["@asteasolutions/zod-to-openapi@7.3.4", "", { "dependencies": { "openapi3-ts": "^4.1.2" }, "peerDependencies": { "zod": "^3.20.2" } }, "sha512-/2rThQ5zPi9OzVwes6U7lK1+Yvug0iXu25olp7S0XsYmOqnyMfxH7gdSQjn/+DSOHRg7wnotwGJSyL+fBKdnEA=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.16.2", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-Z/xZ7q79dYeE0afqIk/yFEcRNGEQFcE+H8ssYivUiy+xGZ1mGwT72jpaQZKBwPn3JH4sRCu4KA2lcktBQfcOjg=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-JY9etiVcu1G/pZjaH2vjK/b8z54ujxaWCD1GziO4ADUhRM6m6zm2332bPGcxEfA6TwweiJfNlK6wVZQ0f/X4KQ=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
+8 -3
View File
@@ -6,6 +6,10 @@ import { resolveScheme } from "../core/runtime.js";
import { parseTime } from "../core/time.js"; import { parseTime } from "../core/time.js";
import type { HandlerMap, RuntimeContext } from "../core/types.js"; import type { HandlerMap, RuntimeContext } from "../core/types.js";
function backendElementType(type: ElementType): "link" | "node" {
return type === "pipe" ? "link" : "node";
}
function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> { function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: string): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
return emitApi(ctx, summary, { return emitApi(ctx, summary, {
@@ -18,10 +22,11 @@ function rangeGet(ctx: RuntimeContext, argv: string[], summary: string, path: st
function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> { function realtimeByIdTime(ctx: RuntimeContext, argv: string[]): Promise<void> {
const { values } = parseOptions(argv); const { values } = parseOptions(argv);
const type = validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type");
return emitApi(ctx, "读取实时模拟数据成功", { return emitApi(ctx, "读取实时模拟数据成功", {
method: "GET", method: "GET",
path: "/timeseries/realtime/simulation-results", path: "/timeseries/realtime/simulation-results",
params: { id: requiredString(values, "id"), type: validateChoice(requiredString(values, "type"), ["pipe", "junction"] as const, "--type"), query_time: parseTime(requiredString(values, "time"), "--time") }, params: { id: requiredString(values, "id"), type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time") },
requireProject: true, requireProject: true,
}); });
} }
@@ -32,7 +37,7 @@ function realtimeByTimeProperty(ctx: RuntimeContext, argv: string[]): Promise<vo
return emitApi(ctx, "读取实时属性聚合数据成功", { return emitApi(ctx, "读取实时属性聚合数据成功", {
method: "GET", method: "GET",
path: "/timeseries/realtime/records", path: "/timeseries/realtime/records",
params: { type, query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") }, params: { type: backendElementType(type), query_time: parseTime(requiredString(values, "time"), "--time"), property: validateChoice(requiredString(values, "property"), fieldsFor(type), "--property") },
requireProject: true, requireProject: true,
}); });
} }
@@ -76,7 +81,7 @@ function schemeSimulation(ctx: RuntimeContext, argv: string[]): Promise<void> {
scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true), scheme_name: resolveScheme(ctx, optionalString(values, "scheme"), true),
scheme_type: optionalString(values, "scheme-type") || "simulation", scheme_type: optionalString(values, "scheme-type") || "simulation",
query_time: parseTime(requiredString(values, "time"), "--time"), query_time: parseTime(requiredString(values, "time"), "--time"),
type, type: backendElementType(type),
}; };
if (query === "by-id-time") { if (query === "by-id-time") {
params.id = requiredString(values, "id"); params.id = requiredString(values, "id");
+152 -1
View File
@@ -1011,8 +1011,10 @@
"type": "string", "type": "string",
"enum": [ "enum": [
"request", "request",
"auto",
"always" "always"
] ],
"description": "request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode."
} }
}, },
"required": [ "required": [
@@ -1386,6 +1388,155 @@
} }
} }
}, },
"/api/v1/agent/sessions/{session_id}/credential-refreshes": {
"post": {
"operationId": "post_sessions_session_id_credential_refreshes",
"tags": [
"Agent"
],
"security": [
{
"bearerAuth": []
}
],
"summary": "Resume a waiting agent tool call with refreshed credentials",
"parameters": [
{
"schema": {
"type": "string",
"maxLength": 128
},
"required": true,
"name": "session_id",
"in": "path"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"request_id": {
"type": "string",
"minLength": 1,
"maxLength": 128
}
},
"required": [
"request_id"
]
}
}
}
},
"responses": {
"202": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": {
"nullable": true
}
}
}
}
},
"400": {
"description": "Invalid request",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"401": {
"description": "Authentication required",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"403": {
"description": "Insufficient permission",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"404": {
"description": "Resource not found",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"409": {
"description": "Resource conflict",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"422": {
"description": "Validation error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"502": {
"description": "Upstream dependency error",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
},
"503": {
"description": "Dependency unavailable",
"content": {
"application/problem+json": {
"schema": {
"$ref": "#/components/schemas/ProblemDetails"
}
}
}
}
}
}
},
"/api/v1/agent/sessions/{session_id}/permission-responses": { "/api/v1/agent/sessions/{session_id}/permission-responses": {
"post": { "post": {
"operationId": "post_sessions_session_id_permission_responses", "operationId": "post_sessions_session_id_permission_responses",
+1 -1
View File
@@ -3,7 +3,7 @@
"contracts": { "contracts": {
"agent": { "agent": {
"file": "agent-v1.openapi.json", "file": "agent-v1.openapi.json",
"sha256": "7699d0b59d2710f5179c3880fa9f7de90dee09239718c86ed9ff2ce12e6f4259" "sha256": "94bd8914597c56b6429160e8c556993ac0617ad079de2980a4b6cb9fdf89c039"
} }
} }
} }
+107 -78
View File
@@ -8,7 +8,59 @@ import { fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli"); const cliPath = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/tjwater-cli");
const pythonCliCwd = resolve(dirname(fileURLToPath(import.meta.url)), "../../../TJWaterServerBinary/cli");
const visibleCommandPaths = [
"analysis age",
"analysis burst",
"analysis burst-detection detect",
"analysis burst-detection schemes get",
"analysis burst-detection schemes list",
"analysis contaminant",
"analysis flushing",
"analysis leakage identify",
"analysis leakage schemes get",
"analysis leakage schemes list",
"analysis sensor-placement kmeans",
"analysis valve",
"component option get",
"component option schema",
"data scada get",
"data scada list",
"data scheme get",
"data scheme list",
"data scheme schema",
"data timeseries composite",
"data timeseries composite pipeline-health",
"data timeseries realtime links",
"data timeseries realtime nodes",
"data timeseries realtime simulation-by-id-time",
"data timeseries realtime simulation-by-time-property",
"data timeseries scada query",
"data timeseries scheme links",
"data timeseries scheme node-field",
"data timeseries scheme simulation",
"network get-all-pipes-properties",
"network get-all-pumps-properties",
"network get-all-reservoirs-properties",
"network get-all-tanks-properties",
"network get-all-valves-properties",
"network get-junction-properties",
"network get-pipe-properties",
"network get-pump-properties",
"network get-reservoir-properties",
"network get-tank-properties",
"network get-valve-properties",
"simulation run",
];
const hiddenCommandPaths = [
"analysis burst-location locate",
"analysis burst-location schemes get",
"analysis burst-location schemes list",
"analysis risk network",
"analysis risk pipe-history",
"analysis risk pipe-now",
];
function runCommand(command, args, input, options = {}) { function runCommand(command, args, input, options = {}) {
return new Promise((resolveRun, reject) => { return new Promise((resolveRun, reject) => {
@@ -35,10 +87,6 @@ function runCli(args, input) {
return runCommand(cliPath, args, input); return runCommand(cliPath, args, input);
} }
function runPythonCli(args, input) {
return runCommand("python", ["-m", "tjwater_cli", ...args], input, { cwd: pythonCliCwd });
}
function parseJsonResult(result) { function parseJsonResult(result) {
return JSON.parse(result.stdout); return JSON.parse(result.stdout);
} }
@@ -118,74 +166,31 @@ test("emits structured JSON help compatible with tjwater-cli/v1", async () => {
assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>"); assert.equal(payload.usage, "tjwater-cli simulation run --start-time <START_TIME> --duration <DURATION>");
}); });
test("matches Python CLI help discovery and hidden command behavior", async () => { test("discovers every visible command and keeps internal commands hidden", async () => {
for (const args of [["help"], ["help", "analysis"]]) { const rootResult = await runCli(["help"]);
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]); assert.equal(rootResult.exitCode, 0, rootResult.stderr);
assert.equal(nodeResult.exitCode, pythonResult.exitCode); assert.deepEqual(
assert.deepEqual(parseJsonResult(nodeResult), parseJsonResult(pythonResult)); parseJsonResult(rootResult).commands.map(({ command }) => command),
["analysis", "component", "data", "network", "simulation"],
);
for (const command of visibleCommandPaths) {
const result = await runCli(["help", ...command.split(" ")]);
assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
const payload = parseJsonResult(result);
assert.equal(payload.ok, true, command);
assert.equal(payload.command, command, command);
assert.equal(payload.schema_version, "tjwater-cli/v1", command);
assert.ok(payload.usage, `${command}: missing usage`);
assert.ok(payload.examples.length > 0, `${command}: missing examples`);
} }
const [nodeLeaf, pythonLeaf] = await Promise.all([ for (const command of hiddenCommandPaths) {
runCli(["help", "simulation", "run"]), const result = await runCli(["help", ...command.split(" ")]);
runPythonCli(["help", "simulation", "run"]), assert.equal(result.exitCode, 0, `${command}: ${result.stderr}`);
]); const payload = parseJsonResult(result);
assert.equal(nodeLeaf.exitCode, pythonLeaf.exitCode); assert.equal(payload.ok, false, command);
const nodePayload = parseJsonResult(nodeLeaf); assert.equal(payload.error.code, "COMMAND_NOT_FOUND", command);
const pythonPayload = parseJsonResult(pythonLeaf);
assert.equal(nodePayload.ok, pythonPayload.ok);
assert.equal(nodePayload.schema_version, pythonPayload.schema_version);
assert.equal(nodePayload.command, pythonPayload.command);
assert.equal(nodePayload.summary, pythonPayload.summary);
assert.equal(nodePayload.usage, pythonPayload.usage);
assert.deepEqual(nodePayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })), pythonPayload.options.map(({ name, required, repeated }) => ({ name, required, repeated })));
assert.deepEqual(nodePayload.examples, pythonPayload.examples);
assert.deepEqual(nodePayload.next_commands, pythonPayload.next_commands);
const [nodeHidden, pythonHidden] = await Promise.all([
runCli(["help", "analysis", "risk"]),
runPythonCli(["help", "analysis", "risk"]),
]);
assert.equal(nodeHidden.exitCode, pythonHidden.exitCode);
const nodeError = parseJsonResult(nodeHidden);
const pythonError = parseJsonResult(pythonHidden);
delete nodeError.metadata.generated_at;
delete pythonError.metadata.generated_at;
assert.deepEqual(nodeError, pythonError);
});
test("matches Python CLI leaf help for every visible command", async () => {
const listResult = await runCommand(
"python",
[
"-c",
"from tjwater_cli.registry import COMMAND_DOCS, is_hidden_path\nimport json\nprint(json.dumps([' '.join(path) for path in COMMAND_DOCS if not is_hidden_path(path)], ensure_ascii=False))",
],
undefined,
{ cwd: pythonCliCwd },
);
assert.equal(listResult.exitCode, 0, listResult.stderr);
const commands = JSON.parse(listResult.stdout);
for (const command of commands) {
const args = ["help", ...command.split(" ")];
const [nodeResult, pythonResult] = await Promise.all([runCli(args), runPythonCli(args)]);
assert.equal(nodeResult.exitCode, pythonResult.exitCode, command);
const nodePayload = parseJsonResult(nodeResult);
const pythonPayload = parseJsonResult(pythonResult);
const comparable = (payload) => ({
command: payload.command,
summary: payload.summary,
usage: payload.usage,
examples: payload.examples,
next_commands: payload.next_commands,
options: (payload.options ?? []).map(({ name, required, repeated }) => ({
name,
required,
repeated,
})),
});
assert.deepEqual(comparable(nodePayload), comparable(pythonPayload), command);
} }
}); });
@@ -262,7 +267,27 @@ test("uses project scoped headers for realtime data commands", async () => {
} }
}); });
test("matches Python CLI backend request shape for every command and key variants", async () => { test("maps CLI pipe and junction types to backend link and node types", async () => {
const server = await startJsonServer({ accepted: true });
const auth = { server: server.url, access_token: "token-3", project_id: "project-1" };
const at = "2025-01-02T03:30:00+08:00";
try {
for (const args of [
["data", "timeseries", "realtime", "simulation-by-id-time", "--id", "J1", "--type", "junction", "--time", at],
["data", "timeseries", "realtime", "simulation-by-time-property", "--type", "pipe", "--time", at, "--property", "flow"],
["data", "timeseries", "scheme", "simulation", "--query", "by-id-time", "--id", "P1", "--type", "pipe", "--time", at, "--scheme", "scheme_case"],
]) {
const result = await runCli(["--auth-stdin", ...args], auth);
assert.equal(result.exitCode, 0, result.stderr);
}
const queries = server.seen.map(normalizeSeenRequest).map((request) => request.query.type);
assert.deepEqual(queries, ["node", "link", "link"]);
} finally {
await server.close();
}
});
test("executes every command and key variant against the backend contract", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-")); const tempDir = await mkdtemp(join(tmpdir(), "tjwater-cli-parity-"));
try { try {
const burstFile = join(tempDir, "burst.json"); const burstFile = join(tempDir, "burst.json");
@@ -339,12 +364,16 @@ test("matches Python CLI backend request shape for every command and key variant
]; ];
for (const [name, args] of cases) { for (const [name, args] of cases) {
const [nodeRun, pythonRun] = await Promise.all([ const run = await runAgainstServer(name, runCli, args, auth);
runAgainstServer(`${name} node`, runCli, args, auth), assert.equal(run.exitCode, 0, `${name}: ${run.stderr}`);
runAgainstServer(`${name} python`, runPythonCli, args, auth), assert.equal(run.payload.ok, true, name);
]); assert.equal(run.payload.schema_version, "tjwater-cli/v1", name);
assert.equal(nodeRun.exitCode, pythonRun.exitCode, `${name}: exit\nnode=${nodeRun.stderr}\npython=${pythonRun.stderr}`); assert.ok(run.requests.length > 0, `${name}: no backend request`);
assert.deepEqual(nodeRun.requests, pythonRun.requests, name); for (const request of run.requests) {
assert.match(request.path, /^\/api\/v1\//, name);
assert.equal(request.headers.authorization, "Bearer token", name);
assert.equal(request.headers["x-project-id"], "project-1", name);
}
} }
} finally { } finally {
await rm(tempDir, { force: true, recursive: true }); await rm(tempDir, { force: true, recursive: true });
+44 -4
View File
@@ -13,10 +13,36 @@
"port": 4096 "port": 4096
}, },
"permission": { "permission": {
"*": "ask",
"external_directory": "deny",
"read": {
"*": "allow", "*": "allow",
"external_directory": "ask", ".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"edit": {
"*": "ask",
".env": "deny",
".env.*": "deny",
"*.env": "deny",
"**/.env": "deny",
"**/.env.*": "deny",
"**/*.env": "deny",
"data/**": "deny",
"**/data/**": "deny",
"logs/**": "deny",
"**/logs/**": "deny"
},
"bash": { "bash": {
"*": "allow", "*": "ask",
"rm *": "ask", "rm *": "ask",
"rmdir *": "ask", "rmdir *": "ask",
"mv *": "ask", "mv *": "ask",
@@ -24,9 +50,23 @@
"chown *": "ask", "chown *": "ask",
"sudo *": "ask", "sudo *": "ask",
"curl *": "ask", "curl *": "ask",
"wget *": "ask" "wget *": "ask",
"*.env*": "deny",
"*data/*": "deny",
"* data": "deny",
"*/data": "deny",
"*logs/*": "deny",
"* logs": "deny",
"*/logs": "deny"
}, },
"edit": "ask" "question": "allow",
"task": "deny",
"todo": "allow",
"todoread": "allow",
"todowrite": "allow"
},
"experimental": {
"continue_loop_on_deny": true
}, },
"default_agent": "instruction" "default_agent": "instruction"
} }
+3 -1
View File
@@ -8,7 +8,9 @@
"install:opencode": "bun install --cwd .opencode", "install:opencode": "bun install --cwd .opencode",
"typecheck": "tsc --noEmit -p tsconfig.json", "typecheck": "tsc --noEmit -p tsconfig.json",
"typecheck:opencode": "bun run --cwd .opencode typecheck", "typecheck:opencode": "bun run --cwd .opencode typecheck",
"test": "bun test tests",
"test:cli": "node --test node-tests/cli/*.node.mjs", "test:cli": "node --test node-tests/cli/*.node.mjs",
"test:ci": "bun run contract:check && /usr/bin/node --test node-tests/cli/*.node.mjs && bun test tests",
"dev": "bun --watch src/server.ts", "dev": "bun --watch src/server.ts",
"build": "bun run check", "build": "bun run check",
"check": "bun run typecheck && bun run typecheck:opencode", "check": "bun run typecheck && bun run typecheck:opencode",
@@ -21,7 +23,7 @@
"start:prod": "bun run check && bun src/server.ts" "start:prod": "bun run check && bun src/server.ts"
}, },
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "^1.16.2", "@opencode-ai/sdk": "1.18.13",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
+25
View File
@@ -0,0 +1,25 @@
import type { RuntimeSessionContext } from "../runtime/sessionContext.js";
type BackendContext = Pick<
RuntimeSessionContext,
"accessToken" | "projectId" | "traceId"
>;
export const buildBackendContextHeaders = (
context: BackendContext,
): Record<string, string> => {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
"X-Trace-Id": context.traceId,
};
if (context.accessToken) {
headers.Authorization = `Bearer ${context.accessToken}`;
}
if (context.projectId) {
headers["X-Project-Id"] = context.projectId;
}
return headers;
};
+232
View File
@@ -0,0 +1,232 @@
import { randomUUID } from "node:crypto";
import { type RuntimeSessionContext } from "../runtime/sessionContext.js";
export type CredentialRefreshReason =
| "access_token_expired"
| "access_token_rejected";
export type CredentialRefreshEvent =
| {
type: "credential_refresh_required";
requestId: string;
reason: CredentialRefreshReason;
timeoutMs: number;
}
| {
type: "credential_refreshed";
requestId: string;
}
| {
type: "credential_refresh_failed";
requestId: string;
message: string;
};
type PendingRefresh = {
deadlineAt: number;
promise: Promise<RuntimeSessionContext>;
reason: CredentialRefreshReason;
reject: (error: Error) => void;
requestId: string;
resolve: (context: RuntimeSessionContext) => void;
timer: ReturnType<typeof setTimeout>;
};
type CredentialRefreshListener = (event: CredentialRefreshEvent) => void;
export class CredentialRefreshError extends Error {
override readonly name = "CredentialRefreshError";
constructor(
message: string,
readonly code: "cancelled" | "failed" | "timeout" | "unavailable" = "failed",
) {
super(message);
}
}
const AUTH_EXPIRY_SKEW_MS = 30_000;
export const isRuntimeCredentialExpired = (
context: RuntimeSessionContext,
now = Date.now(),
) => {
if (!context.tokenExpiresAt) {
return false;
}
const expiresAt = Date.parse(context.tokenExpiresAt);
return Number.isFinite(expiresAt) && now >= expiresAt - AUTH_EXPIRY_SKEW_MS;
};
export class CredentialRefreshCoordinator {
private readonly listeners = new Map<
string,
Set<CredentialRefreshListener>
>();
private readonly pending = new Map<string, PendingRefresh>();
constructor(private readonly timeoutMs = 30_000) {}
subscribe(sessionId: string, listener: CredentialRefreshListener) {
const listeners =
this.listeners.get(sessionId) ?? new Set<CredentialRefreshListener>();
listeners.add(listener);
this.listeners.set(sessionId, listeners);
return () => {
listeners.delete(listener);
if (listeners.size === 0) {
this.listeners.delete(sessionId);
}
};
}
request(sessionId: string, reason: CredentialRefreshReason) {
const existing = this.pending.get(sessionId);
if (existing) {
return existing.promise;
}
if (!this.listeners.get(sessionId)?.size) {
return Promise.reject(
new CredentialRefreshError(
"credential refresh channel is unavailable",
"unavailable",
),
);
}
const requestId = `credential-${randomUUID()}`;
let resolvePromise!: (context: RuntimeSessionContext) => void;
let rejectPromise!: (error: Error) => void;
const promise = new Promise<RuntimeSessionContext>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
const timer = setTimeout(() => {
this.fail(sessionId, requestId, "credential refresh timed out", "timeout");
}, this.timeoutMs);
this.pending.set(sessionId, {
deadlineAt: Date.now() + this.timeoutMs,
promise,
reason,
reject: rejectPromise,
requestId,
resolve: resolvePromise,
timer,
});
this.emit(sessionId, {
type: "credential_refresh_required",
requestId,
reason,
timeoutMs: this.timeoutMs,
});
return promise;
}
resolve(
sessionId: string,
requestId: string,
context: RuntimeSessionContext,
) {
const pending = this.pending.get(sessionId);
if (!pending || pending.requestId !== requestId) {
return false;
}
clearTimeout(pending.timer);
this.pending.delete(sessionId);
pending.resolve(context);
this.emit(sessionId, {
type: "credential_refreshed",
requestId,
});
return true;
}
fail(
sessionId: string,
requestId: string,
message: string,
code: CredentialRefreshError["code"] = "failed",
emitFailureEvent = true,
) {
const pending = this.pending.get(sessionId);
if (!pending || pending.requestId !== requestId) {
return false;
}
clearTimeout(pending.timer);
this.pending.delete(sessionId);
pending.reject(new CredentialRefreshError(message, code));
if (emitFailureEvent) {
this.emit(sessionId, {
type: "credential_refresh_failed",
requestId,
message,
});
}
return true;
}
cancelSession(sessionId: string, message = "credential refresh cancelled") {
const pending = this.pending.get(sessionId);
if (!pending) {
return false;
}
return this.fail(
sessionId,
pending.requestId,
message,
"cancelled",
false,
);
}
getPendingRequestId(sessionId: string) {
return this.pending.get(sessionId)?.requestId;
}
getPendingEvent(
sessionId: string,
): Extract<CredentialRefreshEvent, { type: "credential_refresh_required" }> | null {
const pending = this.pending.get(sessionId);
if (!pending) return null;
return {
type: "credential_refresh_required",
requestId: pending.requestId,
reason: pending.reason,
timeoutMs: Math.max(0, pending.deadlineAt - Date.now()),
};
}
private emit(sessionId: string, event: CredentialRefreshEvent) {
for (const listener of this.listeners.get(sessionId) ?? []) {
listener(event);
}
}
}
export const runWithCredentialRefresh = async <T extends { status: number }>(
coordinator: CredentialRefreshCoordinator,
context: RuntimeSessionContext,
execute: (context: RuntimeSessionContext) => Promise<T>,
) => {
let activeContext = context;
let refreshed = false;
if (isRuntimeCredentialExpired(activeContext)) {
activeContext = await coordinator.request(
activeContext.sessionId,
"access_token_expired",
);
refreshed = true;
}
let result = await execute(activeContext);
if (result.status !== 401 || refreshed) {
return result;
}
activeContext = await coordinator.request(
activeContext.sessionId,
"access_token_rejected",
);
result = await execute(activeContext);
return result;
};
+196
View File
@@ -0,0 +1,196 @@
import { spawn } from "node:child_process";
import { type RuntimeSessionContext } from "../runtime/sessionContext.js";
type OutputStream = "stdout" | "stderr";
export type CliExecutionResult = {
outcome: "completed" | "timeout" | "output_limit";
exitCode: number | null;
signal: NodeJS.Signals | null;
status: number;
stderr: string;
stdout: string;
exceededStream?: OutputStream;
};
type ExecuteCliCommandOptions = {
apiBaseUrl: string;
cliPath: string;
maxOutputBytes: number;
terminationGraceMs?: number;
};
const getCompletedStatus = (exitCode: number | null, stdout: string) => {
let errorCode = "";
try {
const payload = JSON.parse(stdout) as { error?: { code?: unknown } };
errorCode =
typeof payload.error?.code === "string" ? payload.error.code : "";
} catch {
errorCode = "";
}
if (errorCode === "HTTP_401" || errorCode === "UNAUTHENTICATED") {
return 401;
}
if (errorCode === "HTTP_403") {
return 403;
}
return exitCode === 0 ? 200 : 502;
};
export const executeCliCommand = async (
context: RuntimeSessionContext,
command: string,
timeoutSec: number,
options: ExecuteCliCommandOptions,
): Promise<CliExecutionResult> => {
const maxOutputBytes = options.maxOutputBytes;
if (!Number.isSafeInteger(maxOutputBytes) || maxOutputBytes <= 0) {
throw new Error("maxOutputBytes must be a positive safe integer");
}
const child = spawn(
options.cliPath,
["--auth-stdin", ...command.split(/\s+/).filter(Boolean)],
{ stdio: ["pipe", "pipe", "pipe"] },
);
const stdoutChunks: Buffer[] = [];
const stderrChunks: Buffer[] = [];
let stdoutBytes = 0;
let stderrBytes = 0;
let terminationReason:
| "timeout"
| "output_limit"
| "execution_error"
| null = null;
let exceededStream: OutputStream | undefined;
let terminationStarted = false;
let settled = false;
let executionError: Error | null = null;
let forceKillTimer: ReturnType<typeof setTimeout> | undefined;
const result = await new Promise<CliExecutionResult>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timeoutTimer);
if (forceKillTimer) {
clearTimeout(forceKillTimer);
}
};
const terminate = (
reason: "timeout" | "output_limit" | "execution_error",
) => {
if (terminationStarted) {
return;
}
terminationStarted = true;
terminationReason = reason;
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGTERM");
}
forceKillTimer = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill("SIGKILL");
}
}, options.terminationGraceMs ?? 1500);
};
const capture = (stream: OutputStream, data: Buffer) => {
if (terminationReason) {
return;
}
const chunks = stream === "stdout" ? stdoutChunks : stderrChunks;
const bytes = stream === "stdout" ? stdoutBytes : stderrBytes;
if (bytes + data.length > maxOutputBytes) {
exceededStream = stream;
terminate("output_limit");
return;
}
chunks.push(data);
if (stream === "stdout") {
stdoutBytes += data.length;
} else {
stderrBytes += data.length;
}
};
const timeoutTimer = setTimeout(() => {
if (child.exitCode === null && child.signalCode === null) {
terminate("timeout");
}
}, timeoutSec * 1000);
child.stdout.on("data", (data: Buffer) => capture("stdout", data));
child.stderr.on("data", (data: Buffer) => capture("stderr", data));
child.stdin.on("error", (error) => {
if (terminationReason === null) {
executionError = error;
terminate("execution_error");
}
});
child.on("error", (error) => {
if (terminationReason === null) {
executionError = error;
terminate("execution_error");
}
});
child.on("close", (exitCode, signal) => {
if (settled) {
return;
}
settled = true;
cleanup();
if (terminationReason === "timeout") {
resolve({
outcome: "timeout",
exitCode,
signal,
status: 504,
stderr: "",
stdout: "",
});
return;
}
if (executionError) {
reject(executionError);
return;
}
if (terminationReason === "output_limit") {
resolve({
outcome: "output_limit",
exceededStream,
exitCode,
signal,
status: 502,
stderr: "",
stdout: "",
});
return;
}
const stdout = Buffer.concat(stdoutChunks, stdoutBytes).toString("utf-8");
const stderr = Buffer.concat(stderrChunks, stderrBytes).toString("utf-8");
resolve({
outcome: "completed",
exitCode,
signal,
status: getCompletedStatus(exitCode, stdout),
stderr,
stdout,
});
});
child.stdin.end(
JSON.stringify({
server: options.apiBaseUrl,
access_token: context.accessToken,
project_id: context.projectId,
}),
);
});
return result;
};
+11 -25
View File
@@ -41,8 +41,8 @@ const envSchema = z
AGENT_INTERNAL_TOKEN: optionalString(), AGENT_INTERNAL_TOKEN: optionalString(),
// Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。 // Agent 前置认证调用后端 /api/v1/agent/auth/context 的超时时间(毫秒)。
AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000), AGENT_AUTH_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
// opencode 运行模式:embedded 会启动本地 CLI 子进程;client 只连接现有 server // 当前仅支持 embedded;保留字段用于让旧 client 配置在启动时明确失败
OPENCODE_MODE: z.enum(["embedded", "client"]).default("embedded"), OPENCODE_MODE: z.literal("embedded").default("embedded"),
// embedded opencode server 的监听地址。 // embedded opencode server 的监听地址。
OPENCODE_HOSTNAME: z.string().default("127.0.0.1"), OPENCODE_HOSTNAME: z.string().default("127.0.0.1"),
// embedded opencode server 的监听端口。 // embedded opencode server 的监听端口。
@@ -55,10 +55,6 @@ const envSchema = z
OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson), OPENCODE_MODEL_OPTIONS: z.string().default(defaultAgentModelOptionsJson),
// opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。 // opencode skills 树目录;会在运行时解析为绝对路径,避免工具 cwd 偏移。
OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"), OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"),
// client 模式下,目标 opencode server 的基础地址。
OPENCODE_CLIENT_BASE_URL: z.string().url().optional(),
// 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。
OPENCODE_BASE_URL: z.string().url().optional(),
// tjwater-cli 可执行文件路径。 // tjwater-cli 可执行文件路径。
TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"), TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"),
// TJWater 后端 API 的基础地址。 // TJWater 后端 API 的基础地址。
@@ -107,6 +103,14 @@ const envSchema = z
LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8), LEARNING_MIN_PROPOSAL_CONFIDENCE: z.coerce.number().min(0).max(1).default(0.8),
// result_ref 持久化存储目录。 // result_ref 持久化存储目录。
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"), RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
// 仅允许 store_render_ref 从该目录导入受控 JSON 包装文件。
RESULT_REF_IMPORT_DIR: z.string().default("./data/result-imports"),
// 单个渲染包装 JSON 的最大导入字节数。
RESULT_REF_IMPORT_MAX_BYTES: z.coerce
.number()
.int()
.positive()
.default(64 * 1024 * 1024),
// result_ref 保留时长(小时)。 // result_ref 保留时长(小时)。
RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168), RESULT_REF_TTL_HOURS: z.coerce.number().int().positive().default(168),
// 定时清理过期 result_ref 的扫描周期(毫秒)。 // 定时清理过期 result_ref 的扫描周期(毫秒)。
@@ -117,13 +121,6 @@ const envSchema = z
.default(3600000), .default(3600000),
}) })
.superRefine((env, ctx) => { .superRefine((env, ctx) => {
if (env.OPENCODE_MODE === "client" && !env.OPENCODE_CLIENT_BASE_URL) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["OPENCODE_CLIENT_BASE_URL"],
message: "OPENCODE_CLIENT_BASE_URL is required when OPENCODE_MODE=client",
});
}
let modelOptions; let modelOptions;
try { try {
modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS); modelOptions = parseAgentModelOptions(env.OPENCODE_MODEL_OPTIONS);
@@ -154,15 +151,4 @@ const envSchema = z
export type AppConfig = z.infer<typeof envSchema>; export type AppConfig = z.infer<typeof envSchema>;
const normalizedEnv = { export const config: AppConfig = envSchema.parse(process.env);
...process.env,
OPENCODE_MODE:
process.env.OPENCODE_MODE ??
(process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL
? "client"
: "embedded"),
OPENCODE_CLIENT_BASE_URL:
process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL,
};
export const config: AppConfig = envSchema.parse(normalizedEnv);
+24 -1
View File
@@ -214,7 +214,12 @@ register("/api/v1/agent/sessions/{session_id}/runs", "post", {
schema: z.object({ schema: z.object({
message: z.string().min(1).max(10000), message: z.string().min(1).max(10000),
model: z.string().optional(), model: z.string().optional(),
approval_mode: z.enum(["request", "always"]).optional(), approval_mode: z
.enum(["request", "auto", "always"])
.optional()
.describe(
"request forwards approval prompts; auto approves only the low-risk allowlist; always approves every prompt not explicitly denied by OpenCode.",
),
}), }),
}, },
}, },
@@ -246,6 +251,24 @@ register("/api/v1/agent/sessions/{session_id}/runs/current", "delete", {
request: { params: SessionId }, request: { params: SessionId },
responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } }, responses: { 202: jsonResponse(JsonObject), 204: { description: "No active run" } },
}); });
register(
"/api/v1/agent/sessions/{session_id}/credential-refreshes",
"post",
{
summary: "Resume a waiting agent tool call with refreshed credentials",
request: {
params: SessionId,
body: {
content: {
"application/json": {
schema: z.object({ request_id: z.string().min(1).max(128) }),
},
},
},
},
responses: { 202: jsonResponse(JsonObject) },
},
);
register( register(
"/api/v1/agent/sessions/{session_id}/permission-responses", "/api/v1/agent/sessions/{session_id}/permission-responses",
"post", "post",
+1 -1
View File
@@ -77,12 +77,12 @@ type TurnReviewInput = {
export class LearningOrchestrator { export class LearningOrchestrator {
private readonly activeReviews = new Set<string>(); private readonly activeReviews = new Set<string>();
private readonly sessionLearningStateStore = new SessionLearningStateStore(); private readonly sessionLearningStateStore = new SessionLearningStateStore();
private readonly skillStore = new SkillStore();
constructor( constructor(
private readonly runtime: OpencodeRuntimeAdapter, private readonly runtime: OpencodeRuntimeAdapter,
private readonly memoryStore: MemoryStore, private readonly memoryStore: MemoryStore,
private readonly transcriptStore: SessionTranscriptStore, private readonly transcriptStore: SessionTranscriptStore,
private readonly skillStore: SkillStore,
) {} ) {}
async initialize() { async initialize() {
+194
View File
@@ -0,0 +1,194 @@
import { type MemoryScope, MemoryStore } from "../memory/store.js";
import {
setRuntimeSessionContext,
type RuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { SkillStore } from "../skills/store.js";
export type MemoryManagerInput = {
action: "add" | "list" | "replace" | "remove";
content?: string;
scope: string;
target_id?: string;
};
export type SkillManagerInput = {
action:
| "list"
| "write_skill"
| "remove_skill"
| "append_pattern"
| "remove_pattern"
| "write_reference"
| "remove_reference"
| "write_script"
| "remove_script";
content?: string;
file_path?: string;
pattern?: string;
skill_path: string;
target_id?: string;
};
export const executeMemoryManager = async (
memoryStore: MemoryStore,
sessionContext: RuntimeSessionContext,
input: MemoryManagerInput,
) => {
const scope: MemoryScope | null =
input.scope === "user"
? "user"
: input.scope === "workspace"
? "workspace"
: null;
if (!scope) {
return rejected(
"memory",
`unsupported scope: ${input.scope}; use exact keyword 'user' or 'workspace'`,
);
}
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
return rejected("memory", "memory writes are disabled for this session");
}
const scopeKey =
scope === "user" ? sessionContext.actorKey : sessionContext.projectKey;
if (input.action === "list") {
setRuntimeSessionContext({
...sessionContext,
memoryListReadScopes: {
...(sessionContext.memoryListReadScopes ?? {}),
[scope]: true,
},
});
return {
ok: true,
kind: "memory",
decision: "accepted",
detail: "memory listed",
items: await memoryStore.list(scope, scopeKey),
target: scope,
};
}
if (input.action === "add") {
if (sessionContext.memoryListReadScopes?.[scope] !== true) {
return {
...rejected(
"memory",
`must list ${scope} memory and review existing entries before add`,
),
target: scope,
};
}
const result = await memoryStore.upsert(scope, scopeKey, {
content: input.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
});
if (!result.entry) {
return rejected("memory", "content rejected by persistence policy");
}
return {
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "deduped",
detail: result.detail,
entry: result.entry,
target: scope,
};
}
const result =
input.action === "replace"
? await memoryStore.replace(scope, scopeKey, input.target_id ?? "", {
content: input.content ?? "",
sessionId: sessionContext.clientSessionId,
source: "tool",
traceId: sessionContext.traceId,
})
: await memoryStore.remove(scope, scopeKey, input.target_id ?? "");
return {
ok: true,
kind: "memory",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: scope,
};
};
export const executeSkillManager = async (
skillStore: SkillStore,
sessionContext: RuntimeSessionContext,
input: SkillManagerInput,
) => {
if (sessionContext.allowLearningWrite === false && input.action !== "list") {
return rejected("skill", "skill writes are disabled for this session");
}
if (input.action === "list") {
const result = await skillStore.list(input.skill_path);
if (!result) {
return rejected(
"skill",
"invalid skill_path; expected a relative path under .opencode/skills",
);
}
return {
ok: true,
kind: "skill",
decision: "accepted",
detail: "skill listed",
references: result.references,
scripts: result.scripts,
skill_path: result.skillPath,
target: result.target,
patterns: result.patterns,
};
}
const result =
input.action === "write_skill"
? await skillStore.writeSkill(input.skill_path, input.content ?? "")
: input.action === "remove_skill"
? await skillStore.removeSkill(input.skill_path)
: input.action === "append_pattern"
? await skillStore.appendPattern(input.skill_path, input.pattern ?? "")
: input.action === "remove_pattern"
? await skillStore.removePattern(input.skill_path, input.target_id ?? "")
: input.action === "write_reference"
? await skillStore.writeReference(
input.skill_path,
input.file_path ?? "",
input.content ?? "",
)
: input.action === "remove_reference"
? await skillStore.removeReference(
input.skill_path,
input.file_path ?? "",
)
: input.action === "write_script"
? await skillStore.writeScript(
input.skill_path,
input.file_path ?? "",
input.content ?? "",
)
: await skillStore.removeScript(
input.skill_path,
input.file_path ?? "",
);
return {
ok: true,
kind: "skill",
decision: result.changed ? "accepted" : "rejected",
detail: result.detail,
target: result.target,
};
};
const rejected = (kind: "memory" | "skill", detail: string) => ({
ok: true,
kind,
decision: "rejected",
detail,
});
+42 -4
View File
@@ -1,4 +1,7 @@
import { readJsonFile } from "../utils/fileStore.js"; import { realpath, stat } from "node:fs/promises";
import { isAbsolute, relative } from "node:path";
import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js";
import { import {
type ResultReferenceKind, type ResultReferenceKind,
type ResultReferenceRecord, type ResultReferenceRecord,
@@ -33,7 +36,11 @@ export type RenderJunctionPayload = {
}; };
export class ResultReferenceResolver { export class ResultReferenceResolver {
constructor(private readonly store: ResultReferenceStore) {} constructor(
private readonly store: ResultReferenceStore,
private readonly importRoot: string,
private readonly importMaxBytes: number,
) {}
// Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。 // Resolver 负责按结果类型做结构校验,Store 只关心授权和落盘。
async register(input: RegisterResultReferenceInput) { async register(input: RegisterResultReferenceInput) {
@@ -63,7 +70,17 @@ export class ResultReferenceResolver {
filePath: string, filePath: string,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">, input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
) { ) {
const raw = await readJsonFile<unknown>(filePath); const resolvedFilePath = await resolvePathInsideRoot(filePath, this.importRoot);
const fileStat = await stat(resolvedFilePath);
if (!fileStat.isFile()) {
throw new Error("render payload path must point to a regular file");
}
if (fileStat.size > this.importMaxBytes) {
throw new Error(
`render payload file exceeds RESULT_REF_IMPORT_MAX_BYTES (${this.importMaxBytes})`,
);
}
const raw = await readJsonFile<unknown>(resolvedFilePath);
if (raw === null) { if (raw === null) {
throw new Error(`render payload file not found: ${filePath}`); throw new Error(`render payload file not found: ${filePath}`);
} }
@@ -78,13 +95,15 @@ export class ResultReferenceResolver {
throw new Error("render payload file does not contain a valid junction render payload"); throw new Error("render payload file does not contain a valid junction render payload");
} }
return this.register({ const record = await this.register({
...input, ...input,
data: payload, data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload, kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1, schemaVersion: 1,
source: RESULT_REFERENCE_SOURCE.agentGenerated, source: RESULT_REFERENCE_SOURCE.agentGenerated,
}); });
await removeFileIfExists(resolvedFilePath);
return record;
} }
async getFullAuthorized( async getFullAuthorized(
@@ -167,6 +186,25 @@ export const extractRenderJunctionPayload = (
}; };
}; };
const resolvePathInsideRoot = async (filePath: string, rootPath: string) => {
if (!isAbsolute(filePath)) {
throw new Error("render payload file_path must be absolute");
}
const [resolvedFilePath, resolvedRootPath] = await Promise.all([
realpath(filePath),
realpath(rootPath),
]);
const relativePath = relative(resolvedRootPath, resolvedFilePath);
if (
relativePath === ".." ||
relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) ||
isAbsolute(relativePath)
) {
throw new Error("render payload file must be inside RESULT_REF_IMPORT_DIR");
}
return resolvedFilePath;
};
const normalizeDataForKind = ( const normalizeDataForKind = (
kind: ResultReferenceKind, kind: ResultReferenceKind,
data: unknown, data: unknown,
+55 -1
View File
@@ -2,6 +2,7 @@ import { Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js"; import { getAgentAuthContext } from "../auth/agentAuth.js";
import { type CredentialRefreshCoordinator } from "../auth/credentialRefresh.js";
import { import {
agentModelOptions, agentModelOptions,
isSupportedModel, isSupportedModel,
@@ -64,7 +65,13 @@ const payloadSchema = z.object({
model: z.string().refine(isSupportedModel, { model: z.string().refine(isSupportedModel, {
message: "unsupported model", message: "unsupported model",
}).optional(), }).optional(),
approval_mode: z.enum(["request", "always"]).optional().default("request"), approval_mode: z
.enum(["request", "auto", "always"])
.optional()
.default("request")
.describe(
"request forwards approval prompts; auto approves only low-risk allowlisted tools; always approves every prompt not explicitly denied by OpenCode",
),
}); });
const createSessionPayloadSchema = z.object({ const createSessionPayloadSchema = z.object({
@@ -121,6 +128,7 @@ export const buildChatRouter = (
sessionTranscriptStore: SessionTranscriptStore, sessionTranscriptStore: SessionTranscriptStore,
learningOrchestrator: LearningOrchestrator, learningOrchestrator: LearningOrchestrator,
resultReferenceResolver: ResultReferenceResolver, resultReferenceResolver: ResultReferenceResolver,
credentialRefreshCoordinator: CredentialRefreshCoordinator,
) => { ) => {
const chatRouter = Router(); const chatRouter = Router();
@@ -295,6 +303,16 @@ export const buildChatRouter = (
}, },
}; };
run.subscribers.add(subscriber); run.subscribers.add(subscriber);
const pendingCredentialRefresh =
credentialRefreshCoordinator.getPendingEvent(sessionRecord.sessionId);
if (pendingCredentialRefresh) {
subscriber.write(pendingCredentialRefresh.type, {
session_id: sessionRecord.sessionId,
request_id: pendingCredentialRefresh.requestId,
reason: pendingCredentialRefresh.reason,
timeout_ms: pendingCredentialRefresh.timeoutMs,
});
}
const cleanup = () => { const cleanup = () => {
run.subscribers.delete(subscriber); run.subscribers.delete(subscriber);
@@ -390,6 +408,7 @@ export const buildChatRouter = (
registerChatInteractionRoutes(chatRouter, { registerChatInteractionRoutes(chatRouter, {
activeRuns, activeRuns,
credentialRefreshCoordinator,
runtime, runtime,
sessionMetadataStore, sessionMetadataStore,
sessionUiStateStore, sessionUiStateStore,
@@ -803,6 +822,35 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
}); });
}; };
const unsubscribeCredentialRefresh = credentialRefreshCoordinator.subscribe(
binding.sessionId,
(event) => {
publish(event.type, {
session_id: clientSessionId,
request_id: event.requestId,
...(event.type === "credential_refresh_required"
? {
reason: event.reason,
timeout_ms: event.timeoutMs,
}
: {}),
...(event.type === "credential_refresh_failed"
? { message: event.message }
: {}),
});
},
);
const cancelCredentialRefreshOnAbort = () => {
credentialRefreshCoordinator.cancelSession(
binding.sessionId,
"credential refresh cancelled because the agent run was aborted",
);
};
abortController.signal.addEventListener(
"abort",
cancelCredentialRefreshOnAbort,
{ once: true },
);
try { try {
const preparedMessage = await buildPromptWithLearningContext( const preparedMessage = await buildPromptWithLearningContext(
@@ -925,6 +973,12 @@ export const buildChatRouter = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
}); });
sessionBridge.finalizeRequest(clientSessionId); sessionBridge.finalizeRequest(clientSessionId);
abortController.signal.removeEventListener(
"abort",
cancelCredentialRefreshOnAbort,
);
credentialRefreshCoordinator.cancelSession(binding.sessionId);
unsubscribeCredentialRefresh();
activeRun.status = abortController.signal.aborted activeRun.status = abortController.signal.aborted
? activeRun.status === "aborted" ? activeRun.status === "aborted"
? "aborted" ? "aborted"
+72
View File
@@ -2,8 +2,13 @@ import { type Router } from "express";
import { z } from "zod"; import { z } from "zod";
import { getAgentAuthContext } from "../auth/agentAuth.js"; import { getAgentAuthContext } from "../auth/agentAuth.js";
import { type CredentialRefreshCoordinator } from "../auth/credentialRefresh.js";
import { logger } from "../logger.js"; import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js"; import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import {
getRuntimeSessionContext,
setRuntimeSessionContext,
} from "../runtime/sessionContext.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js"; import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
@@ -26,8 +31,13 @@ const questionReplyPayloadSchema = z.object({
answers: z.array(z.array(z.string().max(2000))).default([]), answers: z.array(z.array(z.string().max(2000))).default([]),
}); });
const credentialRefreshPayloadSchema = z.object({
request_id: z.string().min(1).max(128),
});
type RegisterInteractionRoutesOptions = { type RegisterInteractionRoutesOptions = {
activeRuns: Map<string, ActiveRun>; activeRuns: Map<string, ActiveRun>;
credentialRefreshCoordinator: CredentialRefreshCoordinator;
runtime: OpencodeRuntimeAdapter; runtime: OpencodeRuntimeAdapter;
sessionMetadataStore: SessionMetadataStore; sessionMetadataStore: SessionMetadataStore;
sessionUiStateStore: SessionUiStateStore; sessionUiStateStore: SessionUiStateStore;
@@ -41,11 +51,73 @@ export const registerChatInteractionRoutes = (
chatRouter: Router, chatRouter: Router,
{ {
activeRuns, activeRuns,
credentialRefreshCoordinator,
runtime, runtime,
sessionMetadataStore, sessionMetadataStore,
sessionUiStateStore, sessionUiStateStore,
}: RegisterInteractionRoutesOptions, }: RegisterInteractionRoutesOptions,
) => { ) => {
chatRouter.post("/sessions/:session_id/credential-refreshes", async (req, res) => {
const parsed = credentialRefreshPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const authContext = getAgentAuthContext(req);
const actorKey = toActorKey(authContext.userId);
const projectKey = toProjectKey(authContext.projectId);
const sessionRecord = await sessionMetadataStore.get(
{
actorKey,
projectId: authContext.projectId,
projectKey,
userId: authContext.userId,
},
req.params.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const current = getRuntimeSessionContext(sessionRecord.sessionId);
if (!current || current.actorKey !== actorKey || current.projectKey !== projectKey) {
res.status(409).json({ message: "runtime session context unavailable" });
return;
}
if (
credentialRefreshCoordinator.getPendingRequestId(sessionRecord.sessionId) !==
parsed.data.request_id
) {
res.status(409).json({ message: "credential refresh request is no longer pending" });
return;
}
const refreshedContext = {
...current,
accessToken: authContext.accessToken,
authExpired: undefined,
network: authContext.network,
projectId: authContext.projectId,
tokenExpiresAt: authContext.tokenExpiresAt,
traceId: req.header("x-trace-id")?.trim() || current.traceId,
};
setRuntimeSessionContext(refreshedContext);
credentialRefreshCoordinator.resolve(
sessionRecord.sessionId,
parsed.data.request_id,
refreshedContext,
);
res.status(202).json({
session_id: sessionRecord.sessionId,
request_id: parsed.data.request_id,
status: "accepted",
});
});
chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => { chatRouter.post("/sessions/:session_id/permission-responses", async (req, res) => {
const parsed = permissionReplyPayloadSchema.safeParse(req.body); const parsed = permissionReplyPayloadSchema.safeParse(req.body);
if (!parsed.success) { if (!parsed.success) {
+56
View File
@@ -0,0 +1,56 @@
export type ApprovalMode = "request" | "auto" | "always";
const lowRiskToolPermissions = new Set([
"apply_layer_style",
"geocode",
"locate_features",
"render_junctions",
"show_chart",
"view_history",
"view_scada",
"web_search",
"zoom_to_map",
]);
const normalizePermission = (permission: string) => permission.trim().toLowerCase();
export const canAutoApprovePermission = (permission: string): boolean => {
const normalized = normalizePermission(permission);
if (lowRiskToolPermissions.has(normalized)) {
return true;
}
if (normalized.startsWith("tjwater_")) {
return lowRiskToolPermissions.has(normalized.slice("tjwater_".length));
}
return false;
};
export const resolvePermissionApproval = (
approvalMode: ApprovalMode,
permission: string,
) => {
if (approvalMode === "always") {
return {
autoApprove: true,
title: "已按始终允许模式放行",
detail:
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
} as const;
}
if (approvalMode === "auto" && canAutoApprovePermission(permission)) {
return {
autoApprove: true,
title: "已自动批准低风险权限",
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
} as const;
}
return {
autoApprove: false,
title: "等待权限确认",
detail: undefined,
} as const;
};
+78 -86
View File
@@ -46,6 +46,10 @@ import {
type TodoItemPayload, type TodoItemPayload,
type TodoUpdatePayload, type TodoUpdatePayload,
} from "./chatStreamEvents.js"; } from "./chatStreamEvents.js";
import {
resolvePermissionApproval,
type ApprovalMode,
} from "./chatPermissionPolicy.js";
export { export {
collectTextContent, collectTextContent,
@@ -55,7 +59,7 @@ export {
type TodoUpdatePayload, type TodoUpdatePayload,
} from "./chatStreamEvents.js"; } from "./chatStreamEvents.js";
export type ApprovalMode = "request" | "always"; export type { ApprovalMode } from "./chatPermissionPolicy.js";
type StreamPromptOptions = { type StreamPromptOptions = {
runtime: OpencodeRuntimeAdapter; runtime: OpencodeRuntimeAdapter;
@@ -107,24 +111,40 @@ const toRuntimeModel = (model?: SupportedModel) => {
}; };
}; };
const emitFallbackMessage = async ( const emitFinalMessage = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
sessionId: string, sessionId: string,
clientSessionId: string, clientSessionId: string,
currentAssistantMessageIds: Set<string>,
assistantTextParts: Map<string, Map<string, string>>,
write: (event: string, data: Record<string, unknown>) => void, write: (event: string, data: Record<string, unknown>) => void,
) => { ) => {
let text = [...currentAssistantMessageIds]
.reverse()
.map((messageId) => [...(assistantTextParts.get(messageId)?.values() ?? [])].join(""))
.find((content) => content.length > 0) ?? "";
if (!text) {
const messages = await runtime.messages(sessionId); const messages = await runtime.messages(sessionId);
const assistantMessage = [...messages] const assistantMessage = [...messages]
.reverse() .reverse()
.find((message) => message.info.role === "assistant"); .find(
const parts = assistantMessage?.parts ?? []; (message) =>
const text = collectTextContent(parts); message.info.role === "assistant" &&
(currentAssistantMessageIds.size === 0 ||
currentAssistantMessageIds.has(message.info.id)),
);
text = collectTextContent(assistantMessage?.parts ?? []);
}
if (text) { if (text) {
write("token", { write("token", {
session_id: clientSessionId, session_id: clientSessionId,
content: text, content: text,
}); });
return true;
} }
return false;
}; };
export const streamPromptResponse = async ({ export const streamPromptResponse = async ({
@@ -152,15 +172,14 @@ export const streamPromptResponse = async ({
const emittedToolParts = new Set<string>(); const emittedToolParts = new Set<string>();
const emittedQuestionToolParts = new Set<string>(); const emittedQuestionToolParts = new Set<string>();
const emittedQuestionRequestIds = new Set<string>(); const emittedQuestionRequestIds = new Set<string>();
const currentAssistantMessageIds = new Set<string>();
const assistantTextParts = new Map<string, Map<string, string>>();
const partTypes = new Map<string, Part["type"]>(); const partTypes = new Map<string, Part["type"]>();
const pendingPartTextDeltas = new Map<string, string[]>(); const pendingTextDeltas = new Map<string, string[]>();
const reasoningDeltas = new Map<string, string[]>();
const reasoningStatuses = new Map<string, "running" | "completed">(); const reasoningStatuses = new Map<string, "running" | "completed">();
const toolStatuses = new Map<string, string>(); const toolStatuses = new Map<string, string>();
let firstSessionEventLogged = false; let firstSessionEventLogged = false;
let firstNonStatusEventLogged = false; let firstNonStatusEventLogged = false;
let firstTokenLogged = false;
let firstReasoningLogged = false;
let firstToolEventLogged = false; let firstToolEventLogged = false;
let lastSessionStatus: string | null = null; let lastSessionStatus: string | null = null;
let lastSessionStatusMessage: string | null = null; let lastSessionStatusMessage: string | null = null;
@@ -372,6 +391,10 @@ export const streamPromptResponse = async ({
if (isPermissionAskedEvent(event)) { if (isPermissionAskedEvent(event)) {
sawResponseActivity = true; sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.permission,
);
logDevelopmentDebug("permission request received", { logDevelopmentDebug("permission request received", {
...debugContext, ...debugContext,
requestId: event.properties.id, requestId: event.properties.id,
@@ -382,23 +405,20 @@ export const streamPromptResponse = async ({
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
status: approvalMode === "always" ? "completed" : "running", status: permissionApproval.autoApprove ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", title: permissionApproval.title,
detail: detail: permissionApproval.detail ?? buildPermissionDetail(event),
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionDetail(event),
}); });
if (approvalMode === "always") { if (permissionApproval.autoApprove) {
await runtime.replyPermission({ await runtime.replyPermission({
requestId: event.properties.id, requestId: event.properties.id,
sessionId, sessionId,
reply: "always", reply: "once",
}); });
write("permission_response", { write("permission_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.id, request_id: event.properties.id,
reply: "always" satisfies PermissionReply, reply: "once" satisfies PermissionReply,
}); });
continue; continue;
} }
@@ -417,6 +437,10 @@ export const streamPromptResponse = async ({
if (isPermissionV2AskedEvent(event)) { if (isPermissionV2AskedEvent(event)) {
sawResponseActivity = true; sawResponseActivity = true;
const permissionApproval = resolvePermissionApproval(
approvalMode,
event.properties.action,
);
logDevelopmentDebug("permission v2 request received", { logDevelopmentDebug("permission v2 request received", {
...debugContext, ...debugContext,
requestId: event.properties.id, requestId: event.properties.id,
@@ -427,23 +451,20 @@ export const streamPromptResponse = async ({
emitProgress({ emitProgress({
id: `permission-${event.properties.id}`, id: `permission-${event.properties.id}`,
phase: "permission", phase: "permission",
status: approvalMode === "always" ? "completed" : "running", status: permissionApproval.autoApprove ? "completed" : "running",
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认", title: permissionApproval.title,
detail: detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
approvalMode === "always"
? "当前批准模式为始终允许,已自动允许本次权限请求。"
: buildPermissionV2Detail(event),
}); });
if (approvalMode === "always") { if (permissionApproval.autoApprove) {
await runtime.replyPermission({ await runtime.replyPermission({
requestId: event.properties.id, requestId: event.properties.id,
sessionId, sessionId,
reply: "always", reply: "once",
}); });
write("permission_response", { write("permission_response", {
session_id: clientSessionId, session_id: clientSessionId,
request_id: event.properties.id, request_id: event.properties.id,
reply: "always" satisfies PermissionReply, reply: "once" satisfies PermissionReply,
}); });
continue; continue;
} }
@@ -620,45 +641,26 @@ export const streamPromptResponse = async ({
if (event.type === "message.updated") { if (event.type === "message.updated") {
if (event.properties.info.role === "assistant") { if (event.properties.info.role === "assistant") {
sawResponseActivity = true; sawResponseActivity = true;
currentAssistantMessageIds.add(event.properties.info.id);
} }
continue; continue;
} }
if (event.type === "message.part.delta" && event.properties.field === "text") { if (event.type === "message.part.delta" && event.properties.field === "text") {
sawResponseActivity = true; sawResponseActivity = true;
currentAssistantMessageIds.add(event.properties.messageID);
const partType = partTypes.get(event.properties.partID); const partType = partTypes.get(event.properties.partID);
if (partType === "text") { if (partType === "text") {
if (!firstTokenLogged) { const messageParts = assistantTextParts.get(event.properties.messageID) ?? new Map();
firstTokenLogged = true; messageParts.set(
logDevelopmentDebug("first response token emitted", { event.properties.partID,
...debugContext, `${messageParts.get(event.properties.partID) ?? ""}${event.properties.delta}`,
partId: event.properties.partID, );
elapsedMs: Math.max(0, Date.now() - requestStartedAt), assistantTextParts.set(event.properties.messageID, messageParts);
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
emittedText = true;
write("token", {
session_id: clientSessionId,
content: event.properties.delta,
});
} else if (partType === "reasoning") {
if (!firstReasoningLogged) {
firstReasoningLogged = true;
logDevelopmentDebug("first reasoning delta received", {
...debugContext,
partId: event.properties.partID,
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
});
}
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta);
reasoningDeltas.set(event.properties.partID, pending);
} else if (!partType) { } else if (!partType) {
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? []; const pending = pendingTextDeltas.get(event.properties.partID) ?? [];
pending.push(event.properties.delta); pending.push(event.properties.delta);
pendingPartTextDeltas.set(event.properties.partID, pending); pendingTextDeltas.set(event.properties.partID, pending);
} }
continue; continue;
} }
@@ -667,23 +669,19 @@ export const streamPromptResponse = async ({
sawResponseActivity = true; sawResponseActivity = true;
const part = event.properties.part; const part = event.properties.part;
partTypes.set(part.id, part.type); partTypes.set(part.id, part.type);
if (part.type === "text" || part.type === "reasoning" || part.type === "tool") {
currentAssistantMessageIds.add(part.messageID);
}
if (part.type === "text") { if (part.type === "text") {
const pending = pendingPartTextDeltas.get(part.id) ?? []; const pendingText = (pendingTextDeltas.get(part.id) ?? []).join("");
pendingPartTextDeltas.delete(part.id); pendingTextDeltas.delete(part.id);
for (const content of pending) { const messageParts = assistantTextParts.get(part.messageID) ?? new Map();
emittedText = true; messageParts.set(part.id, part.text || pendingText);
write("token", { assistantTextParts.set(part.messageID, messageParts);
session_id: clientSessionId, } else {
content, pendingTextDeltas.delete(part.id);
});
} }
} else if (part.type === "reasoning") { if (part.type === "reasoning") {
const pending = pendingPartTextDeltas.get(part.id) ?? [];
if (pending.length > 0) {
const existing = reasoningDeltas.get(part.id) ?? [];
reasoningDeltas.set(part.id, existing.concat(pending));
}
pendingPartTextDeltas.delete(part.id);
const reasoningStatus = part.time.end ? "completed" : "running"; const reasoningStatus = part.time.end ? "completed" : "running";
if (reasoningStatuses.get(part.id) !== reasoningStatus) { if (reasoningStatuses.get(part.id) !== reasoningStatus) {
reasoningStatuses.set(part.id, reasoningStatus); reasoningStatuses.set(part.id, reasoningStatus);
@@ -691,14 +689,10 @@ export const streamPromptResponse = async ({
...debugContext, ...debugContext,
partId: part.id, partId: part.id,
status: reasoningStatus, status: reasoningStatus,
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), elapsedMs: Math.max(0, Date.now() - requestStartedAt),
}); });
} }
const reasoningDetail = buildReasoningProgressDetail( const reasoningDetail = buildReasoningProgressDetail(part.time.end);
reasoningDeltas.get(part.id) ?? [],
part.time.end,
);
emitProgress({ emitProgress({
id: part.id, id: part.id,
phase: "planning", phase: "planning",
@@ -778,9 +772,6 @@ export const streamPromptResponse = async ({
detail: buildToolProgressDetail( detail: buildToolProgressDetail(
part.tool, part.tool,
part.state.status, part.state.status,
toolParams,
reason,
part.state.status === "error" ? part.state.error : undefined,
), ),
}); });
if ( if (
@@ -926,13 +917,14 @@ export const streamPromptResponse = async ({
} }
await promptPromise; await promptPromise;
if (!emittedText) { emittedText = await emitFinalMessage(
logDevelopmentDebug("no streamed text emitted, falling back to messages()", { runtime,
...debugContext, sessionId,
elapsedMs: Math.max(0, Date.now() - requestStartedAt), clientSessionId,
}); currentAssistantMessageIds,
await emitFallbackMessage(runtime, sessionId, clientSessionId, write); assistantTextParts,
} write,
);
emitProgress({ emitProgress({
id: "request-received", id: "request-received",
phase: "start", phase: "start",
+5 -59
View File
@@ -356,43 +356,6 @@ export const normalizeToolStatus = (status: string) => {
return "running"; return "running";
}; };
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
}
if (
typeof value === "number" ||
typeof value === "boolean" ||
value === null ||
value === undefined
) {
return String(value);
}
try {
const serialized = JSON.stringify(value);
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
} catch {
return "[unserializable]";
}
};
const normalizeProgressText = (chunks: string[]) =>
chunks.join("").replace(/\s+/g, " ").trim();
const truncateProgressText = (text: string, maxLength: number) =>
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
const summarizeToolParams = (params: Record<string, unknown>) => {
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
const summary = Object.entries(params)
.filter(([key]) => !ignoredKeys.has(key))
.slice(0, 4)
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
.join(", ");
return summary || "无附加参数";
};
export const buildSessionStatusDetail = (status: { type: string; message?: string }) => { export const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
if (status.type === "retry") { if (status.type === "retry") {
return status.message return status.message
@@ -413,42 +376,25 @@ export const buildSessionStatusDetail = (status: { type: string; message?: strin
}; };
export const buildReasoningProgressDetail = ( export const buildReasoningProgressDetail = (
chunks: string[],
ended?: string | number | Date | null, ended?: string | number | Date | null,
) => { ) => ended ? "分析步骤已整理完成。" : "Agent 正在分析问题。";
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
if (ended) {
return reasoningText
? `推理过程:${reasoningText}`
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
}
return reasoningText
? `正在推理:${reasoningText}`
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
};
export const buildToolProgressDetail = ( export const buildToolProgressDetail = (
tool: string, tool: string,
status: string, status: string,
params: Record<string, unknown>,
reason: string,
error?: string,
) => { ) => {
const toolName = toolLabels[tool] ?? tool; const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;调用原因:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
if (status === "error") { if (status === "error") {
const errorText = error ? `;错误:${error}` : ""; return `${toolName} 调用失败。`;
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
} }
if (status === "completed") { if (status === "completed") {
return `${toolName} 已执行完成${reasonText}${paramsText}`; return `${toolName} 已执行完成`;
} }
if (status === "pending") { if (status === "pending") {
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`; return `${toolName} 等待执行。`;
} }
return `${toolName} 正在执行${reasonText}${paramsText}`; return `${toolName} 正在执行`;
}; };
export const getToolProgressTitle = (tool: string, status: string) => { export const getToolProgressTitle = (tool: string, status: string) => {
+60 -15
View File
@@ -1,6 +1,5 @@
import { import {
createOpencode, createOpencode,
createOpencodeClient,
type OpencodeClient, type OpencodeClient,
} from "@opencode-ai/sdk/v2"; } from "@opencode-ai/sdk/v2";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
@@ -64,6 +63,64 @@ export class OpencodeRuntimeAdapter {
return requireData(response.data, "global.health"); return requireData(response.data, "global.health");
} }
async warmup(): Promise<void> {
const client = await this.ensureClient();
const healthStartedAt = Date.now();
const healthResponse = await client.global.health();
const health = requireData(healthResponse.data, "global.health");
logDevelopmentDebug("opencode warmup health check completed", {
elapsedMs: Math.max(0, Date.now() - healthStartedAt),
healthy: health.healthy,
version: health.version,
});
const sessionStartedAt = Date.now();
const sessionResponse = await client.session.create({
title: "tjwater-agent-warmup",
});
const session = requireData(sessionResponse.data, "session.create");
logDevelopmentDebug("opencode warmup session created", {
elapsedMs: Math.max(0, Date.now() - sessionStartedAt),
sessionId: session.id,
});
try {
const [provider, model] = config.OPENCODE_MODEL.split("/");
if (!provider || !model) {
throw new Error(
`invalid OPENCODE_MODEL; expected provider/model, received ${config.OPENCODE_MODEL}`,
);
}
const toolsStartedAt = Date.now();
const toolsResponse = await client.tool.list({ provider, model });
const tools = requireData(toolsResponse.data, "tool.list");
logDevelopmentDebug("opencode warmup tools loaded", {
elapsedMs: Math.max(0, Date.now() - toolsStartedAt),
model: config.OPENCODE_MODEL,
sessionId: session.id,
toolCount: tools.length,
});
} finally {
const cleanupStartedAt = Date.now();
let cleanupSucceeded = true;
await client.session.delete(
{ sessionID: session.id },
{ throwOnError: true },
).catch((error) => {
cleanupSucceeded = false;
logger.warn(
{ err: error, sessionId: session.id },
"failed to remove opencode warmup session",
);
});
logDevelopmentDebug("opencode warmup session cleanup completed", {
elapsedMs: Math.max(0, Date.now() - cleanupStartedAt),
sessionId: session.id,
succeeded: cleanupSucceeded,
});
}
}
async createSession(title?: string) { async createSession(title?: string) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const response = await client.session.create({ const response = await client.session.create({
@@ -329,19 +386,6 @@ export class OpencodeRuntimeAdapter {
} }
private async bootstrapClient(): Promise<OpencodeClient> { private async bootstrapClient(): Promise<OpencodeClient> {
if (config.OPENCODE_MODE === "client") {
logger.info(
{
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
mode: config.OPENCODE_MODE,
},
"connecting to opencode server in client mode",
);
return createOpencodeClient({
baseUrl: config.OPENCODE_CLIENT_BASE_URL,
});
}
// embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里, // embedded 模式下,把服务内工具桥地址注入到 opencode 进程环境里,
// 这样 .opencode/tools 下的自定义工具可以回调本服务。 // 这样 .opencode/tools 下的自定义工具可以回调本服务。
process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`; process.env.TJWATER_AGENT_INTERNAL_BASE_URL = `http://127.0.0.1:${config.PORT}`;
@@ -349,6 +393,7 @@ export class OpencodeRuntimeAdapter {
config.AGENT_INTERNAL_TOKEN ?? config.AGENT_INTERNAL_TOKEN ??
process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? process.env.TJWATER_AGENT_INTERNAL_TOKEN ??
""; "";
process.env.RESULT_REF_IMPORT_DIR = config.RESULT_REF_IMPORT_DIR;
logger.info( logger.info(
{ {
@@ -372,7 +417,7 @@ export class OpencodeRuntimeAdapter {
} catch (error) { } catch (error) {
if (isMissingOpencodeCli(error)) { if (isMissingOpencodeCli(error)) {
throw new Error( throw new Error(
"embedded mode requires the opencode CLI to be installed and available in PATH; otherwise set OPENCODE_MODE=client and provide OPENCODE_CLIENT_BASE_URL", "embedded mode requires the opencode CLI to be installed and available in PATH",
); );
} }
throw error; throw error;
+257 -114
View File
@@ -1,16 +1,28 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import cors from "cors"; import cors from "cors";
import express from "express"; import express from "express";
import { requireAgentAuth } from "./auth/agentAuth.js"; import { requireAgentAuth } from "./auth/agentAuth.js";
import { buildBackendContextHeaders } from "./auth/backendContextHeaders.js";
import {
CredentialRefreshError,
CredentialRefreshCoordinator,
runWithCredentialRefresh,
} from "./auth/credentialRefresh.js";
import { SessionTranscriptStore } from "./sessions/transcriptStore.js"; import { SessionTranscriptStore } from "./sessions/transcriptStore.js";
import { executeCliCommand } from "./cli/executeCliCommand.js";
import { ChatSessionBridge } from "./chat/sessionBridge.js"; import { ChatSessionBridge } from "./chat/sessionBridge.js";
import { config } from "./config.js"; import { config } from "./config.js";
import { SessionUiStateStore } from "./sessions/uiStateStore.js"; import { SessionUiStateStore } from "./sessions/uiStateStore.js";
import { SessionMetadataStore } from "./sessions/metadataStore.js"; import { SessionMetadataStore } from "./sessions/metadataStore.js";
import { logger } from "./logger.js"; import { logger } from "./logger.js";
import { LearningOrchestrator } from "./learning/orchestrator.js"; import { LearningOrchestrator } from "./learning/orchestrator.js";
import {
executeMemoryManager,
executeSkillManager,
type MemoryManagerInput,
type SkillManagerInput,
} from "./learning/toolManagers.js";
import { MemoryStore } from "./memory/store.js"; import { MemoryStore } from "./memory/store.js";
import { ResultReferenceResolver } from "./results/resolver.js"; import { ResultReferenceResolver } from "./results/resolver.js";
import { import {
@@ -25,6 +37,8 @@ import {
markRuntimeSessionAuthExpired, markRuntimeSessionAuthExpired,
type RuntimeSessionContext, type RuntimeSessionContext,
} from "./runtime/sessionContext.js"; } from "./runtime/sessionContext.js";
import { ensureDirectory } from "./utils/fileStore.js";
import { SkillStore } from "./skills/store.js";
const app = express(); const app = express();
@@ -33,17 +47,24 @@ const sessionBridge = new ChatSessionBridge(opencodeRuntime);
const sessionMetadataStore = new SessionMetadataStore(); const sessionMetadataStore = new SessionMetadataStore();
const sessionUiStateStore = new SessionUiStateStore(); const sessionUiStateStore = new SessionUiStateStore();
const memoryStore = new MemoryStore(); const memoryStore = new MemoryStore();
const skillStore = new SkillStore();
const sessionTranscriptStore = new SessionTranscriptStore(); const sessionTranscriptStore = new SessionTranscriptStore();
const learningOrchestrator = new LearningOrchestrator( const learningOrchestrator = new LearningOrchestrator(
opencodeRuntime, opencodeRuntime,
memoryStore, memoryStore,
sessionTranscriptStore, sessionTranscriptStore,
skillStore,
); );
const resultReferenceStore = new ResultReferenceStore(); const resultReferenceStore = new ResultReferenceStore();
const resultReferenceResolver = new ResultReferenceResolver(resultReferenceStore); const resultReferenceResolver = new ResultReferenceResolver(
resultReferenceStore,
config.RESULT_REF_IMPORT_DIR,
config.RESULT_REF_IMPORT_MAX_BYTES,
);
const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID();
const credentialRefreshCoordinator = new CredentialRefreshCoordinator();
// 这个 token 只用于仍需服务端上下文的工具桥(store_render_ref // 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥
process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken;
app.use(cors()); app.use(cors());
@@ -54,6 +75,8 @@ app.get("/health", async (_req, res) => {
const runtime = await opencodeRuntime.health(); const runtime = await opencodeRuntime.health();
res.json({ res.json({
ok: true, ok: true,
ready: true,
warmed_up: true,
runtime, runtime,
sessions: sessionBridge.count(), sessions: sessionBridge.count(),
}); });
@@ -61,6 +84,8 @@ app.get("/health", async (_req, res) => {
const detail = error instanceof Error ? error.message : String(error); const detail = error instanceof Error ? error.message : String(error);
res.status(503).json({ res.status(503).json({
ok: false, ok: false,
ready: false,
warmed_up: true,
message: "opencode runtime unavailable", message: "opencode runtime unavailable",
detail, detail,
sessions: sessionBridge.count(), sessions: sessionBridge.count(),
@@ -68,6 +93,104 @@ app.get("/health", async (_req, res) => {
} }
}); });
app.post("/internal/tools/memory-manager", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({
message: "session context not found",
detail: sessionId,
});
return;
}
const action = req.body?.action;
if (
typeof action !== "string" ||
!["add", "list", "replace", "remove"].includes(action) ||
typeof req.body?.scope !== "string"
) {
res.status(400).json({ message: "invalid memory manager request" });
return;
}
try {
res.json(
await executeMemoryManager(memoryStore, context, {
action: action as MemoryManagerInput["action"],
content:
typeof req.body?.content === "string" ? req.body.content : undefined,
scope: req.body.scope,
target_id:
typeof req.body?.target_id === "string" ? req.body.target_id : undefined,
}),
);
} catch (error) {
res.status(500).json({
message: "memory manager failed",
detail: error instanceof Error ? error.message : String(error),
});
}
});
app.post("/internal/tools/skill-manager", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId =
typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context) {
res.status(404).json({ message: "session context not found", detail: sessionId });
return;
}
const action = req.body?.action;
if (
typeof action !== "string" ||
![
"list",
"write_skill",
"remove_skill",
"append_pattern",
"remove_pattern",
"write_reference",
"remove_reference",
"write_script",
"remove_script",
].includes(action) ||
typeof req.body?.skill_path !== "string"
) {
res.status(400).json({ message: "invalid skill manager request" });
return;
}
try {
res.json(
await executeSkillManager(skillStore, context, {
action: action as SkillManagerInput["action"],
content:
typeof req.body?.content === "string" ? req.body.content : undefined,
file_path:
typeof req.body?.file_path === "string" ? req.body.file_path : undefined,
pattern:
typeof req.body?.pattern === "string" ? req.body.pattern : undefined,
skill_path: req.body.skill_path,
target_id:
typeof req.body?.target_id === "string" ? req.body.target_id : undefined,
}),
);
} catch (error) {
res.status(500).json({
message: "skill manager failed",
detail: error instanceof Error ? error.message : String(error),
});
}
});
app.post("/internal/tools/tjwater-cli-call", async (req, res) => { app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
if (req.header("x-agent-internal-token") !== internalToken) { if (req.header("x-agent-internal-token") !== internalToken) {
res.status(403).json({ message: "forbidden" }); res.status(403).json({ message: "forbidden" });
@@ -84,15 +207,6 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
}); });
return; return;
} }
if (isRuntimeAuthExpired(context)) {
markAuthExpired(context, "access_token_expired");
res.status(401).json({
message: "access token expired; refresh chat context",
detail: sessionId,
});
return;
}
const command = typeof req.body?.command === "string" ? req.body.command.trim() : ""; const command = typeof req.body?.command === "string" ? req.body.command.trim() : "";
if (!command) { if (!command) {
res.status(400).json({ message: "command is required" }); res.status(400).json({ message: "command is required" });
@@ -110,46 +224,40 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
return; return;
} }
const authJson = JSON.stringify({ let result;
server: config.TJWATER_API_BASE_URL, try {
access_token: context.accessToken, result = await runWithCredentialRefresh(
project_id: context.projectId, credentialRefreshCoordinator,
context,
(activeContext) =>
executeCliCommand(activeContext, command, timeoutSec, {
apiBaseUrl: config.TJWATER_API_BASE_URL,
cliPath: config.TJWATER_CLI_PATH,
maxOutputBytes: config.MAX_INLINE_RESULT_BYTES,
}),
);
} catch (error) {
if (!(error instanceof CredentialRefreshError)) {
const detail = error instanceof Error ? error.message : String(error);
res.status(502).json({
message: "CLI execution failed",
detail,
}); });
return;
}
if (error.code === "cancelled") {
res.status(409).json({ message: "agent run was aborted" });
return;
}
markAuthExpired(context, "access_token_expired");
res.status(401).json({
message: "credential refresh failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
const cliArgs = ["--auth-stdin", ...command.split(/\s+/).filter(Boolean)]; if (result.status === 504) {
const child = spawn(config.TJWATER_CLI_PATH, cliArgs, {
stdio: ["pipe", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString("utf-8");
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString("utf-8");
});
child.stdin.write(authJson);
child.stdin.end();
const exitCode = await new Promise<number | null>((resolve, reject) => {
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve(-1);
}, timeoutSec * 1000);
child.on("close", (code) => {
clearTimeout(timer);
resolve(code);
});
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
if (exitCode === -1) {
res.status(504).json({ res.status(504).json({
ok: false, ok: false,
schema_version: "tjwater-cli/v1", schema_version: "tjwater-cli/v1",
@@ -163,25 +271,50 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => {
return; return;
} }
if (exitCode !== 0) { if (result.outcome === "output_limit") {
res.status(502).json({ res.status(502).json({
ok: false, ok: false,
exit_code: exitCode, schema_version: "tjwater-cli/v1",
stderr: stderr.slice(0, 2000), summary: "CLI 输出超过安全限制",
stdout: stdout.slice(0, 2000), error: {
message: `CLI exited with code ${exitCode}`, code: "OUTPUT_LIMIT_EXCEEDED",
message: `${result.exceededStream ?? "output"} exceeded ${config.MAX_INLINE_RESULT_BYTES} bytes`,
retryable: false,
},
}); });
return; return;
} }
if (result.status === 401) {
markAuthExpired(
getRuntimeSessionContext(sessionId) ?? context,
"access_token_rejected",
);
}
if (result.exitCode !== 0) {
res
.status(result.status)
.type("application/json")
.send(
result.stdout ||
JSON.stringify({
ok: false,
exit_code: result.exitCode,
stderr: result.stderr.slice(0, 2000),
message: `CLI exited with code ${result.exitCode}`,
}),
);
return;
}
try { try {
res.json(JSON.parse(stdout)); res.json(JSON.parse(result.stdout));
} catch { } catch {
res.json({ res.json({
ok: true, ok: true,
schema_version: "tjwater-cli/v1", schema_version: "tjwater-cli/v1",
raw: stdout, raw: result.stdout,
stderr: stderr || undefined, stderr: result.stderr || undefined,
}); });
} }
}); });
@@ -276,45 +409,60 @@ const callBackendJson = async (
context: RuntimeSessionContext, context: RuntimeSessionContext,
payload: unknown, payload: unknown,
) => { ) => {
if (isRuntimeAuthExpired(context)) { try {
const result = await runWithCredentialRefresh(
credentialRefreshCoordinator,
context,
async (activeContext) => {
const controller = new AbortController();
const timer = setTimeout(
() => controller.abort(),
config.TJWATER_API_TIMEOUT_MS,
);
try {
const response = await fetch(
new URL(path, config.TJWATER_API_BASE_URL),
{
method: "POST",
headers: buildBackendContextHeaders(activeContext),
body: JSON.stringify(payload),
signal: controller.signal,
},
);
return {
ok: response.ok,
status: response.status,
text: await response.text(),
};
} finally {
clearTimeout(timer);
}
},
);
if (result.status === 401) {
markAuthExpired(
getRuntimeSessionContext(context.sessionId) ?? context,
"access_token_rejected",
);
}
return result;
} catch (error) {
if (!(error instanceof CredentialRefreshError)) {
throw error;
}
if (error.code === "cancelled") {
throw error;
}
markAuthExpired(context, "access_token_expired"); markAuthExpired(context, "access_token_expired");
return { return {
ok: false, ok: false,
status: 401, status: 401,
text: JSON.stringify({ text: JSON.stringify({
message: "access token expired; refresh chat context", message: "credential refresh failed",
detail: error instanceof Error ? error.message : String(error),
}), }),
}; };
} }
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), config.TJWATER_API_TIMEOUT_MS);
try {
const headers: Record<string, string> = {
Accept: "application/json",
"Content-Type": "application/json",
};
if (context.accessToken) {
headers.Authorization = `Bearer ${context.accessToken}`;
}
const response = await fetch(new URL(path, config.TJWATER_API_BASE_URL), {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: controller.signal,
});
const text = await response.text();
if (response.status === 401) {
markAuthExpired(context, "access_token_rejected");
}
return {
ok: response.ok,
status: response.status,
text,
};
} finally {
clearTimeout(timer);
}
}; };
const parseStringArray = (value: unknown) => const parseStringArray = (value: unknown) =>
@@ -337,19 +485,6 @@ const normalizeWebSearchFreshness = (value: unknown) => {
return webSearchFreshnessMap[value] ?? value; return webSearchFreshnessMap[value] ?? value;
}; };
const AUTH_EXPIRY_SKEW_MS = 30_000;
function isRuntimeAuthExpired(context: RuntimeSessionContext) {
if (!context.tokenExpiresAt) {
return false;
}
const expiresAt = Date.parse(context.tokenExpiresAt);
if (!Number.isFinite(expiresAt)) {
return false;
}
return Date.now() >= expiresAt - AUTH_EXPIRY_SKEW_MS;
}
function markAuthExpired( function markAuthExpired(
context: RuntimeSessionContext, context: RuntimeSessionContext,
reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"], reason: NonNullable<RuntimeSessionContext["authExpired"]>["reason"],
@@ -471,6 +606,7 @@ const chatRouter = buildChatRouter(
sessionTranscriptStore, sessionTranscriptStore,
learningOrchestrator, learningOrchestrator,
resultReferenceResolver, resultReferenceResolver,
credentialRefreshCoordinator,
); );
const authenticatedChatRouter = express.Router(); const authenticatedChatRouter = express.Router();
authenticatedChatRouter.use(requireAgentAuth, chatRouter); authenticatedChatRouter.use(requireAgentAuth, chatRouter);
@@ -486,25 +622,15 @@ const bootstrap = async () => {
learningOrchestrator.initialize(), learningOrchestrator.initialize(),
memoryStore.initialize(), memoryStore.initialize(),
resultReferenceStore.initialize(), resultReferenceStore.initialize(),
ensureDirectory(config.RESULT_REF_IMPORT_DIR),
sessionTranscriptStore.initialize(), sessionTranscriptStore.initialize(),
]); ]);
resultReferenceStore.startCleanupLoop();
}; };
await bootstrap();
const server = app.listen(config.PORT, config.HOST, () => {
logger.info(
{ host: config.HOST, port: config.PORT },
"TJWaterAgent listening",
);
void warmupOpencodeRuntime();
});
const warmupOpencodeRuntime = async () => { const warmupOpencodeRuntime = async () => {
const startedAt = Date.now(); const startedAt = Date.now();
try { try {
await opencodeRuntime.ensureClient(); await opencodeRuntime.warmup();
logger.info( logger.info(
{ {
elapsedMs: Math.max(0, Date.now() - startedAt), elapsedMs: Math.max(0, Date.now() - startedAt),
@@ -521,9 +647,26 @@ const warmupOpencodeRuntime = async () => {
}, },
"failed to warm up opencode runtime", "failed to warm up opencode runtime",
); );
throw error;
} }
}; };
await bootstrap();
await warmupOpencodeRuntime();
resultReferenceStore.startCleanupLoop();
const server = app.listen(config.PORT, config.HOST, () => {
logger.info(
{
host: config.HOST,
port: config.PORT,
ready: true,
warmedUp: true,
},
"TJWaterAgent listening",
);
});
const shutdown = async () => { const shutdown = async () => {
logger.info("shutting down TJWaterAgent"); logger.info("shutting down TJWaterAgent");
server.close(); server.close();
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test";
import { buildBackendContextHeaders } from "../../src/auth/backendContextHeaders.js";
describe("buildBackendContextHeaders", () => {
it("forwards authenticated project and trace context to the backend", () => {
expect(
buildBackendContextHeaders({
accessToken: "access-token-1",
projectId: "project-id-1",
traceId: "trace-id-1",
}),
).toEqual({
Accept: "application/json",
"Content-Type": "application/json",
Authorization: "Bearer access-token-1",
"X-Project-Id": "project-id-1",
"X-Trace-Id": "trace-id-1",
});
});
it("omits optional authentication and project headers when unavailable", () => {
expect(buildBackendContextHeaders({ traceId: "trace-id-2" })).toEqual({
Accept: "application/json",
"Content-Type": "application/json",
"X-Trace-Id": "trace-id-2",
});
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, test } from "bun:test";
import {
CredentialRefreshCoordinator,
CredentialRefreshError,
runWithCredentialRefresh,
} from "../../src/auth/credentialRefresh.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const context = (overrides: Partial<RuntimeSessionContext> = {}) => ({
accessToken: "old-token",
actorKey: "user-1",
clientSessionId: "client-1",
projectId: "project-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
...overrides,
});
describe("CredentialRefreshCoordinator", () => {
test("deduplicates concurrent refreshes for one session", async () => {
const coordinator = new CredentialRefreshCoordinator();
const requestIds: string[] = [];
coordinator.subscribe("session-1", (event) => {
if (event.type === "credential_refresh_required") {
requestIds.push(event.requestId);
}
});
const expired = context({ tokenExpiresAt: new Date(0).toISOString() });
const execute = async (active: RuntimeSessionContext) => ({
status: 200,
token: active.accessToken,
});
const first = runWithCredentialRefresh(coordinator, expired, execute);
const second = runWithCredentialRefresh(coordinator, expired, execute);
await Promise.resolve();
expect(requestIds).toHaveLength(1);
expect(coordinator.getPendingEvent("session-1")).toMatchObject({
type: "credential_refresh_required",
requestId: requestIds[0],
reason: "access_token_expired",
});
coordinator.resolve(
"session-1",
requestIds[0]!,
context({ accessToken: "fresh-token" }),
);
expect(await first).toEqual({ status: 200, token: "fresh-token" });
expect(await second).toEqual({ status: 200, token: "fresh-token" });
expect(coordinator.getPendingEvent("session-1")).toBeNull();
});
test("retries one time on 401 and never refreshes a 403", async () => {
const coordinator = new CredentialRefreshCoordinator();
let requestId = "";
coordinator.subscribe("session-1", (event) => {
if (event.type === "credential_refresh_required") {
requestId = event.requestId;
}
});
let attempts = 0;
const resultPromise = runWithCredentialRefresh(
coordinator,
context(),
async () => ({ status: ++attempts === 1 ? 401 : 401 }),
);
await Promise.resolve();
coordinator.resolve("session-1", requestId, context({ accessToken: "fresh-token" }));
expect((await resultPromise).status).toBe(401);
expect(attempts).toBe(2);
requestId = "";
expect(
(await runWithCredentialRefresh(coordinator, context(), async () => ({ status: 403 })))
.status,
).toBe(403);
expect(requestId).toBe("");
});
test("fails explicitly when no event stream can refresh credentials", async () => {
await expect(
runWithCredentialRefresh(
new CredentialRefreshCoordinator(),
context({ tokenExpiresAt: new Date(0).toISOString() }),
async () => ({ status: 200 }),
),
).rejects.toBeInstanceOf(CredentialRefreshError);
});
test("reports run cancellation separately from authentication failure", async () => {
const coordinator = new CredentialRefreshCoordinator();
coordinator.subscribe("session-1", () => undefined);
const pending = coordinator.request("session-1", "access_token_rejected");
coordinator.cancelSession("session-1");
await expect(pending).rejects.toMatchObject({ code: "cancelled" });
});
});
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, test } from "bun:test";
import { fileURLToPath } from "node:url";
import { executeCliCommand } from "../../src/cli/executeCliCommand.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
const cliPath = fileURLToPath(
new URL("../fixtures/fakeCli.mjs", import.meta.url),
);
const context: RuntimeSessionContext = {
accessToken: "test-token",
actorKey: "actor-1",
clientSessionId: "client-1",
projectId: "project-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
};
const run = (
command: string,
options: {
maxOutputBytes?: number;
terminationGraceMs?: number;
timeoutSec?: number;
} = {},
) =>
executeCliCommand(context, command, options.timeoutSec ?? 1, {
apiBaseUrl: "http://127.0.0.1:8000",
cliPath,
maxOutputBytes: options.maxOutputBytes ?? 64,
terminationGraceMs: options.terminationGraceMs ?? 20,
});
describe("executeCliCommand", () => {
test("accepts output at the byte limit", async () => {
await expect(run("stdout 123456", { maxOutputBytes: 6 })).resolves.toMatchObject({
outcome: "completed",
exitCode: 0,
status: 200,
stdout: "123456",
});
});
test("rejects multibyte output above the byte limit without returning a partial body", async () => {
await expect(run("stdout 水水", { maxOutputBytes: 5 })).resolves.toMatchObject({
outcome: "output_limit",
exceededStream: "stdout",
status: 502,
stderr: "",
stdout: "",
});
});
test("limits stderr independently", async () => {
await expect(run("stderr 1234567", { maxOutputBytes: 6 })).resolves.toMatchObject({
outcome: "output_limit",
exceededStream: "stderr",
status: 502,
stderr: "",
stdout: "",
});
});
test("waits for a SIGTERM-aware process to close after timeout", async () => {
const startedAt = Date.now();
const result = await run("term", {
terminationGraceMs: 100,
timeoutSec: 0.25,
});
expect(result).toMatchObject({ outcome: "timeout", status: 504 });
expect(Date.now() - startedAt).toBeGreaterThanOrEqual(270);
});
test("uses SIGKILL when a timed-out process ignores SIGTERM", async () => {
const result = await run("ignore-term", { timeoutSec: 0.25 });
expect(result).toMatchObject({
outcome: "timeout",
signal: "SIGKILL",
status: 504,
});
});
test("keeps the timeout outcome when closing stdin also errors", async () => {
const largeContext = {
...context,
accessToken: "x".repeat(1024 * 1024),
};
await expect(
executeCliCommand(largeContext, "ignore-term", 0.25, {
apiBaseUrl: "http://127.0.0.1:8000",
cliPath,
maxOutputBytes: 64,
terminationGraceMs: 20,
}),
).resolves.toMatchObject({
outcome: "timeout",
signal: "SIGKILL",
status: 504,
});
});
test("rejects a deterministic stdin pipe error without crashing", async () => {
const largeContext = {
...context,
accessToken: "x".repeat(1024 * 1024),
};
await expect(
executeCliCommand(largeContext, "closed-stdin", 1, {
apiBaseUrl: "http://127.0.0.1:8000",
cliPath,
maxOutputBytes: 64,
terminationGraceMs: 20,
}),
).rejects.toBeInstanceOf(Error);
});
});
+29 -1
View File
@@ -47,7 +47,7 @@ describe("Agent REST OpenAPI", () => {
} }
} }
expect(operationCount).toBe(13); expect(operationCount).toBe(14);
}); });
test("models runs as session subresources", () => { test("models runs as session subresources", () => {
@@ -61,6 +61,34 @@ describe("Agent REST OpenAPI", () => {
expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined(); expect(document.paths["/api/v1/agent/chat/stream"]).toBeUndefined();
}); });
test("separates automatic approval from persistent permission grants", () => {
const document = generateAgentOpenApi();
const runRequest = document.paths["/api/v1/agent/sessions/{session_id}/runs"]
?.post?.requestBody;
const permissionRequest = document.paths[
"/api/v1/agent/sessions/{session_id}/permission-responses"
]?.post?.requestBody;
expect(
runRequest && !("$ref" in runRequest)
? runRequest.content["application/json"]?.schema
: undefined,
).toMatchObject({
properties: {
approval_mode: { enum: ["request", "auto", "always"] },
},
});
expect(
permissionRequest && !("$ref" in permissionRequest)
? permissionRequest.content["application/json"]?.schema
: undefined,
).toMatchObject({
properties: {
reply: { enum: ["once", "always", "reject"] },
},
});
});
test("matches the public session runtime response shapes", () => { test("matches the public session runtime response shapes", () => {
const document = generateAgentOpenApi(); const document = generateAgentOpenApi();
const schemas = document.components?.schemas ?? {}; const schemas = document.components?.schemas ?? {};
Vendored Executable
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env node
import { closeSync } from "node:fs";
const command = process.argv[3];
const value = process.argv[4] ?? "";
if (command === "stdout") {
process.stdout.write(value);
process.exit(0);
}
if (command === "stderr") {
process.stderr.write(value);
process.exit(1);
}
if (command === "term") {
process.on("SIGTERM", () => {
setTimeout(() => process.exit(0), 30);
});
setInterval(() => undefined, 1000);
}
if (command === "ignore-term") {
process.on("SIGTERM", () => undefined);
setInterval(() => undefined, 1000);
}
if (command === "closed-stdin") {
closeSync(0);
setInterval(() => undefined, 1000);
}
+104
View File
@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
executeMemoryManager,
executeSkillManager,
} from "../../src/learning/toolManagers.js";
import { MemoryStore } from "../../src/memory/store.js";
import {
getRuntimeSessionContext,
removeRuntimeSessionContext,
setRuntimeSessionContext,
type RuntimeSessionContext,
} from "../../src/runtime/sessionContext.js";
import { SkillStore } from "../../src/skills/store.js";
describe("main-process learning tool managers", () => {
let tempDir: string;
let memoryStore: MemoryStore;
let skillStore: SkillStore;
let context: RuntimeSessionContext;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-learning-tools-"));
memoryStore = new MemoryStore(
join(tempDir, "memory"),
join(tempDir, "backup", "memory"),
);
skillStore = new SkillStore(
join(tempDir, "skills"),
join(tempDir, "backup", "skills"),
);
await memoryStore.initialize();
context = {
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "client-session-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
};
setRuntimeSessionContext(context);
});
afterEach(async () => {
removeRuntimeSessionContext(context.sessionId);
await rm(tempDir, { force: true, recursive: true });
});
it("enforces list-before-add using the canonical runtime context", async () => {
const rejected = await executeMemoryManager(memoryStore, context, {
action: "add",
content: "用户偏好查看压力单位为 MPa",
scope: "user",
});
expect(rejected.decision).toBe("rejected");
await executeMemoryManager(memoryStore, context, {
action: "list",
scope: "user",
});
const refreshedContext = getRuntimeSessionContext(context.sessionId)!;
const accepted = await executeMemoryManager(memoryStore, refreshedContext, {
action: "add",
content: "用户偏好查看压力单位为 MPa",
scope: "user",
});
expect(accepted.decision).toBe("accepted");
expect(await memoryStore.list("user", context.actorKey)).toHaveLength(1);
});
it("writes and removes skills through the shared store", async () => {
const content = [
"---",
"name: pressure-review",
"description: Pressure review workflow.",
"---",
"",
"# Pressure Review",
].join("\n");
const written = await executeSkillManager(skillStore, context, {
action: "write_skill",
content,
skill_path: "workflow/pressure-review",
});
expect(written.decision).toBe("accepted");
expect("target" in written).toBe(true);
if (!("target" in written)) throw new Error("write returned no target");
await expect(readFile(written.target, "utf8")).resolves.toContain(
"# Pressure Review\n",
);
const removed = await executeSkillManager(skillStore, context, {
action: "remove_skill",
skill_path: "workflow/pressure-review",
});
expect(removed.decision).toBe("accepted");
expect("target" in removed).toBe(true);
if (!("target" in removed)) throw new Error("remove returned no target");
await expect(readFile(removed.target, "utf8")).rejects.toThrow();
});
});
-140
View File
@@ -1,140 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createSkillManagerTool } from "../../.opencode/tools/skill_manager.js";
import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js";
import { SkillStore } from "../../src/skills/store.js";
describe("skill_manager tool", () => {
let tempDir: string;
let skillStore: SkillStore;
let context: RuntimeSessionContext;
const toolContext = {
abort: new AbortController().signal,
agent: "test",
ask: (() => undefined) as never,
directory: "",
messageID: "message-1",
metadata: () => undefined,
sessionID: "session-1",
worktree: "",
};
const skillDocument = (body: string) =>
[
"---",
"name: pressure-review",
"description: Pressure review workflow.",
"---",
"",
body,
].join("\n");
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-skill-tool-"));
skillStore = new SkillStore(
join(tempDir, "skills"),
join(tempDir, "backup", "skills"),
);
context = {
actorKey: "actor-1",
allowLearningWrite: true,
clientSessionId: "client-session-1",
projectKey: "project-1",
sessionId: "session-1",
traceId: "trace-1",
};
});
afterEach(async () => {
await rm(tempDir, { force: true, recursive: true });
});
it("dispatches skill-level write, overwrite, and remove actions", async () => {
const tool = createSkillManagerTool(
skillStore,
{ read: () => context },
Promise.resolve(),
);
const writeResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Pressure Review"),
reason: "verified reusable workflow",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# Pressure Review\n",
);
const updateResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: skillDocument("# Updated Pressure Review"),
reason: "verified reusable workflow overwrite",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(updateResult.decision).toBe("accepted");
await expect(readFile(updateResult.target, "utf8")).resolves.toContain(
"# Updated Pressure Review\n",
);
const removeResult = JSON.parse(
await tool.execute(
{
action: "remove_skill",
reason: "workflow is obsolete",
skill_path: "workflow/pressure-review",
},
toolContext,
) as string,
);
expect(removeResult.decision).toBe("accepted");
await expect(readFile(removeResult.target, "utf8")).rejects.toThrow();
});
it("writes the root skills index through the reserved alias", async () => {
const tool = createSkillManagerTool(
skillStore,
{ read: () => context },
Promise.resolve(),
);
const writeResult = JSON.parse(
await tool.execute(
{
action: "write_skill",
content: [
"---",
"name: skills",
"description: TJWater Skills root index.",
"---",
"",
"# TJWater Skills",
].join("\n"),
reason: "refresh root skills index",
skill_path: "__root__",
},
toolContext,
) as string,
);
expect(writeResult.decision).toBe("accepted");
await expect(readFile(writeResult.target, "utf8")).resolves.toContain(
"# TJWater Skills\n",
);
});
});
+49 -2
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
@@ -18,7 +18,7 @@ describe("ResultReferenceResolver", () => {
beforeEach(async () => { beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-")); tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
store = new ResultReferenceStore(tempDir, 60_000); store = new ResultReferenceStore(tempDir, 60_000);
resolver = new ResultReferenceResolver(store); resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024);
await store.initialize(); await store.initialize();
}); });
@@ -193,6 +193,53 @@ describe("ResultReferenceResolver", () => {
"DMA-2": "#00ff00", "DMA-2": "#00ff00",
}, },
}); });
await expect(stat(filePath)).rejects.toThrow();
});
it("rejects render payload files outside the configured import directory", async () => {
const outsideDir = await mkdtemp(join(tmpdir(), "tjwater-result-outside-"));
const filePath = join(outsideDir, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
try {
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-4",
clientSessionId: "client-4",
projectKey: "project-key-4",
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-4",
}),
).rejects.toThrow("RESULT_REF_IMPORT_DIR");
} finally {
await rm(outsideDir, { force: true, recursive: true });
}
});
it("rejects oversized render payload files before parsing", async () => {
const filePath = join(tempDir, "oversized.json");
await writeFile(filePath, "x".repeat(128), "utf8");
const sizeLimitedResolver = new ResultReferenceResolver(store, tempDir, 64);
await expect(
sizeLimitedResolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-5",
clientSessionId: "client-5",
projectKey: "project-key-5",
sessionId: "session-5",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-5",
}),
).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES");
}); });
}); });
+153
View File
@@ -0,0 +1,153 @@
import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
import express, { Router } from "express";
import type { Server } from "node:http";
import { CredentialRefreshCoordinator } from "../../src/auth/credentialRefresh.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import type { ActiveRun } from "../../src/routes/chatUiState.js";
describe("chat interaction routes", () => {
let baseUrl = "";
let server: Server;
const replyQuestion = mock(async () => ({ ok: true }));
const replyPermission = mock(async () => ({ ok: true }));
beforeAll(async () => {
const activeRuns = new Map<string, ActiveRun>();
activeRuns.set("runtime-session", {
clientSessionId: "client-session",
controller: new AbortController(),
messages: [
{
id: "assistant-1",
role: "assistant",
permissions: [
{
requestId: "permission-1",
sessionId: "runtime-session",
permission: "bash",
patterns: ["npm test"],
always: ["npm test"],
createdAt: 1,
status: "pending",
},
],
questions: [{ requestId: "question-1", status: "pending" }],
},
],
pendingPermissions: new Map([
[
"permission-1",
{
session_id: "runtime-session",
request_id: "permission-1",
permission: "bash",
patterns: ["npm test"],
always: ["npm test"],
created_at: 1,
},
],
]),
pendingQuestions: new Map([
[
"question-1",
{
created_at: 1,
request_id: "question-1",
session_id: "runtime-session",
questions: [],
},
],
]),
status: "running",
subscribers: new Set(),
});
const router = Router();
router.use((req, _res, next) => {
req.agentAuth = {
accessToken: "access-token",
userId: "user-1",
keycloakSub: "keycloak-1",
username: "tester",
role: "user",
isSuperuser: false,
projectId: "project-1",
network: "network-1",
projectRole: "member",
};
next();
});
registerChatInteractionRoutes(router, {
activeRuns,
credentialRefreshCoordinator: new CredentialRefreshCoordinator(),
runtime: { replyPermission, replyQuestion } as never,
sessionMetadataStore: {
get: async () => ({ sessionId: "runtime-session" }),
} as never,
sessionUiStateStore: {
read: async () => null,
write: async () => undefined,
} as never,
});
const app = express();
app.use(express.json());
app.use(router);
server = app.listen(0);
await new Promise<void>((resolve) => server.once("listening", resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("test server did not expose a TCP port");
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(() => {
server.close();
});
it("submits answers to the stable OpenCode question adapter", async () => {
const response = await fetch(
`${baseUrl}/sessions/client-session/question-responses`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: "question-1",
action: "reply",
answers: [["继续"]],
}),
},
);
expect(response.status).toBe(202);
expect(replyQuestion).toHaveBeenCalledWith({
requestId: "question-1",
sessionId: "runtime-session",
answers: [["继续"]],
});
});
it("forwards saved permission grants to OpenCode", async () => {
const response = await fetch(
`${baseUrl}/sessions/client-session/permission-responses`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
request_id: "permission-1",
reply: "always",
}),
},
);
expect(response.status).toBe(202);
expect(replyPermission).toHaveBeenCalledWith({
requestId: "permission-1",
sessionId: "runtime-session",
reply: "always",
message: undefined,
});
});
});
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from "bun:test";
import {
canAutoApprovePermission,
resolvePermissionApproval,
} from "../../src/routes/chatPermissionPolicy.js";
describe("permission approval policy", () => {
it.each([
"show_chart",
"web_search",
])("allows low-risk permission %s", (permission) => {
expect(canAutoApprovePermission(permission)).toBe(true);
});
it.each([
"bash",
"edit",
"external_directory",
"store_render_ref",
"tjwater_server_query",
"tjwater_tjwater_server_query",
])(
"requires confirmation for permission %s",
(permission) => {
expect(canAutoApprovePermission(permission)).toBe(false);
},
);
it("resolves request, auto, and always modes", () => {
expect(resolvePermissionApproval("request", "show_chart").autoApprove).toBe(false);
expect(resolvePermissionApproval("auto", "show_chart").autoApprove).toBe(true);
expect(resolvePermissionApproval("auto", "bash").autoApprove).toBe(false);
expect(resolvePermissionApproval("always", "bash")).toMatchObject({
autoApprove: true,
title: "已按始终允许模式放行",
});
});
});
+325 -8
View File
@@ -15,6 +15,240 @@ const createEventStream = (events: unknown[]) => ({
}); });
describe("streamPromptResponse", () => { describe("streamPromptResponse", () => {
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
partID: "text-part-intermediate",
field: "text",
delta: "正在加载工作流并尝试分页参数。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "text-part-final",
field: "text",
delta: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
time: { start: 3, end: 4 },
},
time: 4,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [
{
info: { id: "assistant-intermediate", role: "assistant" },
parts: [
{
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
},
],
},
{
info: { id: "assistant-final", role: "assistant" },
parts: [
{
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
],
},
],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
});
it("uses the final text event cache when the messages lookup fails", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "最终分析结果。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => {
throw new Error("transient messages lookup failure");
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
expect(result.failed).toBe(false);
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("最终分析结果。");
});
it("keeps reasoning, tool parameters, and raw errors out of progress details", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "reasoning-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "reasoning",
text: "内部推理:尝试 limit=5000 并读取临时路径。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-1",
partID: "reasoning-part-1",
field: "text",
delta: "内部推理:尝试 limit=5000 并读取临时路径。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "tool",
callID: "call-1",
tool: "tjwater_cli",
state: {
status: "error",
input: {
command: "network get-all-pipes-properties --limit 5000",
reason: "尝试突破分页限制",
},
error: "HTTP_422 raw backend payload with trace_id=secret-trace",
time: { start: 1, end: 2 },
},
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网瓶颈",
write: (event, data) => events.push({ event, data }),
});
const reasoningProgress = events.find(
(item) => item.event === "progress" && item.data.id === "reasoning-part-1",
);
const toolProgress = events.find(
(item) => item.event === "progress" && item.data.id === "tool-part-1",
);
expect(reasoningProgress?.data.detail).toBe("分析步骤已整理完成。");
expect(toolProgress?.data.detail).toBe("tjwater_cli 调用失败。");
});
it("forwards opencode permission requests as SSE payloads", async () => { it("forwards opencode permission requests as SSE payloads", async () => {
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
@@ -61,7 +295,7 @@ describe("streamPromptResponse", () => {
} satisfies Partial<PermissionRequestPayload>); } satisfies Partial<PermissionRequestPayload>);
}); });
it("auto replies always when approval mode is always", async () => { it("auto approves an allowlisted low-risk permission once", async () => {
const replies: Array<Record<string, unknown>> = []; const replies: Array<Record<string, unknown>> = [];
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
@@ -71,10 +305,10 @@ describe("streamPromptResponse", () => {
properties: { properties: {
id: "perm-1", id: "perm-1",
sessionID: "runtime-session-1", sessionID: "runtime-session-1",
permission: "bash", permission: "show_chart",
patterns: ["npm test"], patterns: ["*"],
metadata: { command: "npm test" }, metadata: {},
always: ["npm test"], always: ["*"],
}, },
}, },
{ {
@@ -97,7 +331,7 @@ describe("streamPromptResponse", () => {
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
clientSessionId: "client-session-1", clientSessionId: "client-session-1",
message: "run tests", message: "run tests",
approvalMode: "always", approvalMode: "auto",
write: (event, data) => events.push({ event, data }), write: (event, data) => events.push({ event, data }),
}); });
@@ -105,17 +339,100 @@ describe("streamPromptResponse", () => {
{ {
requestId: "perm-1", requestId: "perm-1",
sessionId: "runtime-session-1", sessionId: "runtime-session-1",
reply: "always", reply: "once",
}, },
]); ]);
expect(events.some((item) => item.event === "permission_request")).toBe(false); expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({ expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1", session_id: "client-session-1",
request_id: "perm-1", request_id: "perm-1",
reply: "always", reply: "once",
}); });
}); });
it("keeps high-risk permissions interactive in auto mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-auto-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "auto",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([]);
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
request_id: "perm-auto-bash",
permission: "bash",
});
});
it("approves every OpenCode ask once in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-bash",
sessionId: "runtime-session-1",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
});
it("forwards opencode v2 permission requests as SSE payloads", async () => { it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = { const runtime = {
subscribeEvents: async () => subscribeEvents: async () =>
+1
View File
@@ -55,6 +55,7 @@ describe("Agent public REST router", () => {
undefined as never, undefined as never,
undefined as never, undefined as never,
undefined as never, undefined as never,
undefined as never,
); );
const layers = (router as unknown as { stack: RouterLayer[] }).stack; const layers = (router as unknown as { stack: RouterLayer[] }).stack;
const runtimeOperations = layers const runtimeOperations = layers
+72
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test"; import { describe, expect, it } from "bun:test";
import { type OpencodeClient } from "@opencode-ai/sdk/v2"; import { type OpencodeClient } from "@opencode-ai/sdk/v2";
import { config } from "../../src/config.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
const createRuntimeAdapter = ( const createRuntimeAdapter = (
@@ -85,3 +86,74 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
expect(attempts).toBe(2); expect(attempts).toBe(2);
}); });
}); });
describe("OpencodeRuntimeAdapter.warmup", () => {
it("initializes the project session and model tools before reporting ready", async () => {
const calls: string[] = [];
const client = {
global: {
health: async () => {
calls.push("health");
return { data: { healthy: true, version: "test" } };
},
},
session: {
create: async () => {
calls.push("session.create");
return { data: { id: "warmup-session" } };
},
delete: async ({ sessionID }: { sessionID: string }) => {
calls.push(`session.delete:${sessionID}`);
return { data: true };
},
},
tool: {
list: async (model: { provider: string; model: string }) => {
calls.push(`tool.list:${model.provider}/${model.model}`);
return { data: [] };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.warmup();
expect(calls).toEqual([
"health",
"session.create",
`tool.list:${config.OPENCODE_MODEL}`,
"session.delete:warmup-session",
]);
});
it("submits question answers through the stable question API", async () => {
const calls: unknown[] = [];
const client = {
question: {
reply: async (input: unknown) => {
calls.push(input);
return { data: { ok: true } };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.replyQuestion({
requestId: "question-1",
sessionId: "session-1",
answers: [["继续"]],
});
expect(calls).toEqual([
{ requestID: "question-1", answers: [["继续"]] },
]);
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";
describe("internal OpenCode permissions", () => {
it("keeps protected paths denied in every approval mode", async () => {
const config = JSON.parse(await readFile("opencode.json", "utf8")) as {
permission?: Record<string, string | Record<string, string>>;
};
const permission = config.permission ?? {};
const bash = permission.bash as Record<string, string> | undefined;
const edit = permission.edit as Record<string, string> | undefined;
const read = permission.read as Record<string, string> | undefined;
expect(permission["*"]).toBe("ask");
expect(permission.external_directory).toBe("deny");
expect(permission.task).toBe("deny");
expect(permission.question).toBe("allow");
expect(permission.todowrite).toBe("allow");
expect(read?.["*"]).toBe("allow");
expect(read?.["data/**"]).toBe("deny");
expect(read?.["**/logs/**"]).toBe("deny");
expect(edit?.["*"]).toBe("ask");
expect(edit?.["data/**"]).toBe("deny");
expect(edit?.["**/logs/**"]).toBe("deny");
expect(bash?.["*"]).toBe("ask");
expect(bash?.["*.env*"]).toBe("deny");
expect(bash?.["*data/*"]).toBe("deny");
expect(bash?.["*logs/*"]).toBe("deny");
});
});