Compare commits

..

No commits in common. "main" and "v0.9.x" have entirely different histories.
main ... v0.9.x

2365 changed files with 17042 additions and 582072 deletions

View file

@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Ask a question or discuss a topic
url: https://discord.com/invite/BkMR2NUsjU
about: Ask questions and discuss with other community members
- name: Join Yao community
url: https://yaoapps.com/community
about: Join the community to get updates and news

View file

@ -1,13 +0,0 @@
---
name: "Report an issue"
about: "Report an issue to help us improve"
labels: ""
assignees: ""
---
## Description
## Context
- **Yao Version( yao version --all )**:
- **Platform**:

View file

@ -1,4 +1,4 @@
FROM docker:latest
FROM docker:stable
COPY entrypoint.sh /entrypoint.sh

View file

@ -6,9 +6,6 @@ inputs:
description: "The name of database"
required: false
default: "github"
port:
description: "The port of database"
required: false
user:
description: "The user of database"
required: false

View file

@ -3,18 +3,11 @@
docker_run="docker run"
startMySQL() {
PORT="3306"
if [ ! -z "$INPUT_PORT" ]; then
PORT=$INPUT_PORT
fi
VERSION=$1
echo "Start MySQL $VERSION"
docker_run="$docker_run -e MYSQL_RANDOM_ROOT_PASSWORD=true -e MYSQL_USER=$INPUT_USER -e MYSQL_PASSWORD=$INPUT_PASSWORD"
docker_run="$docker_run -e MYSQL_DATABASE=$INPUT_DB"
docker_run="$docker_run -d -p $PORT:3306 mysql:$VERSION --port=3306 --sql-mode=''"
docker_run="$docker_run -d -p 3306:3306 mysql:$VERSION --port=3306 --sql-mode=''"
if [ "$VERSION" = "5.6" ]; then
docker_run="$docker_run --character-set-server=utf8 --collation-server=utf8_general_ci"
@ -24,7 +17,7 @@ startMySQL() {
sh -c "$docker_run"
DB_HOST="tcp(127.0.0.1:$PORT)/$INPUT_DB?charset=utf8mb4&parseTime=True&loc=Local"
DB_HOST="tcp(127.0.0.1:3306)/$INPUT_DB?charset=utf8mb4&parseTime=True&loc=Local"
DB_USER=$INPUT_USER
echo "DB_HOST=$DB_HOST" >> $GITHUB_ENV
echo "DB_USER=$DB_USER" >> $GITHUB_ENV

View file

@ -1,113 +0,0 @@
name: "Setup Yao Build Environment"
description: "Checkout dependency repos, setup Go toolchain, and install build tools (v1.0.0)"
inputs:
go-version:
description: "Go version to install"
default: "1.25"
repo-kun:
description: "Kun repository (owner/repo)"
required: true
repo-xun:
description: "Xun repository (owner/repo)"
required: true
repo-gou:
description: "Gou repository (owner/repo)"
required: true
checkout-app:
description: "Checkout yao-dev-app (demo application for tests)"
default: "true"
checkout-init:
description: "Checkout yao-init (for Yao server startup in CI)"
default: "false"
apple-private-key:
description: "Apple private key content for OAuth certs (optional)"
default: ""
runs:
using: "composite"
steps:
# -- Dependency repositories --
- name: Checkout Kun
uses: actions/checkout@v4
with:
repository: ${{ inputs.repo-kun }}
path: kun
- name: Checkout Xun
uses: actions/checkout@v4
with:
repository: ${{ inputs.repo-xun }}
path: xun
- name: Checkout Gou
uses: actions/checkout@v4
with:
repository: ${{ inputs.repo-gou }}
path: gou
- name: Checkout V8Go
uses: actions/checkout@v4
with:
repository: yaoapp/v8go
path: v8go
- name: Unzip libv8
shell: bash
run: |
for file in $(find ./v8go -name "libv8*.zip"); do
dir=$(dirname "$file")
echo "Extracting $file to $dir"
unzip -o -d "$dir" "$file"
rm -rf "$dir/__MACOSX"
done
- name: Checkout Demo App
if: ${{ inputs.checkout-app == 'true' }}
uses: actions/checkout@v4
with:
repository: yaoapp/yao-dev-app
path: app
- name: Checkout Extension
uses: actions/checkout@v4
with:
repository: yaoapp/yao-extensions-dev
path: extension
- name: Checkout yao-init
if: ${{ inputs.checkout-init == 'true' }}
uses: actions/checkout@v4
with:
repository: yaoapp/yao-init
path: yao-init
# -- Move all dependencies to parent directory (Go workspace layout) --
- name: Move Dependencies
shell: bash
run: |
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
[ -d app ] && mv app ../
mv extension ../
[ -d yao-init ] && mv yao-init ../
# -- Setup Apple Private Key (if provided) --
- name: Setup Apple Private Key
if: ${{ inputs.apple-private-key != '' }}
shell: bash
run: |
mkdir -p ../app/openapi/certs/apple
echo "${{ inputs.apple-private-key }}" > ../app/openapi/certs/apple/signin_client_secret_key.p8
# -- Go toolchain --
- name: Setup Go ${{ inputs.go-version }}
uses: actions/setup-go@v5
with:
go-version: ${{ inputs.go-version }}
- name: Setup Go Tools
shell: bash
run: make tools

View file

@ -1,10 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
</dict>
</plist>

View file

@ -1,141 +0,0 @@
# ============================================================
# Yao CI Environment — sandbox-v2 (v1.0.0)
# Loaded via: cat .github/env/sandbox-v2.env >> $GITHUB_ENV
# ============================================================
# ========================================
# Yao Runtime (YAO_ prefix, read by Yao)
# ========================================
YAO_HOST=0.0.0.0
YAO_PORT=5099
YAO_GRPC_HOST=0.0.0.0
YAO_GRPC_PORT=9099
YAO_DB_DRIVER=sqlite3
YAO_SESSION=memory
YAO_ENV=development
# ========================================
# CI Test Parameters (YAO_CI_ prefix)
# ========================================
# -- Network --
YAO_CI_BRIDGE_IP=172.17.0.1
# -- Yao service ports (tests read these, not YAO_PORT/YAO_GRPC_PORT) --
YAO_CI_HTTP_PORT=5099
YAO_CI_GRPC_PORT=9099
YAO_CI_URL=http://127.0.0.1:5099
YAO_CI_GRPC=127.0.0.1:9099
# -- OAuth token generation (ci-token tool) --
YAO_CI_OAUTH_SUBJECT=ci-test-user
YAO_CI_OAUTH_USER_ID=ci-test-user
YAO_CI_OAUTH_TEAM_ID=ci-test-team
YAO_CI_OAUTH_SCOPE=tai:tunnel
YAO_CI_OAUTH_TTL=24h
# ========================================
# Tai Instances
#
# Connection modes:
# tai-local → DIRECT (--direct, Yao dials Tai gRPC directly)
# tai-docker → TUNNEL (default, Tai dials Yao gRPC, reverse tunnel)
# tai-k8s → TUNNEL (same as above, with K8s runtime)
# tai-hostexec → TUNNEL (same as above, no container runtime)
# ========================================
# -- tai-local (DIRECT mode, auto-detect Docker via /var/run/docker.sock) --
# Yao dials tai-local gRPC directly, so gRPC port must be reachable.
# Docker proxy on 12376 (not default 12375) to avoid conflict with tai-docker.
YAO_CI_TAI_LOCAL_HOST=127.0.0.1
YAO_CI_TAI_LOCAL_GRPC_PORT=19103
YAO_CI_TAI_LOCAL_HTTP_PORT=8102
YAO_CI_TAI_LOCAL_VNC_PORT=16083
YAO_CI_TAI_LOCAL_DOCKER_PORT=12376
YAO_CI_TAI_LOCAL_GRPC=127.0.0.1:19103
YAO_CI_TAI_LOCAL_DOCKER_API=tcp://127.0.0.1:12376
# -- tai-docker (TUNNEL mode, explicit Docker API proxy) --
# Tunnel: Tai connects to Yao gRPC. Sandbox connects to Yao, traffic forwarded via tunnel.
# gRPC port used only for Tai's own listener; Yao accesses via tunnel, not direct dial.
YAO_CI_TAI_DOCKER_HOST=127.0.0.1
YAO_CI_TAI_DOCKER_GRPC_PORT=19100
YAO_CI_TAI_DOCKER_HTTP_PORT=8099
YAO_CI_TAI_DOCKER_VNC_PORT=16080
YAO_CI_TAI_DOCKER_API_PORT=12375
YAO_CI_TAI_DOCKER_API=tcp://127.0.0.1:12375
# -- tai-k8s (TUNNEL mode, K8s API proxy via k3d) --
# K8s proxy on 16444 (not 16443) because k3d --api-port already binds 16443.
# TAI_K8S_UPSTREAM points to k3d at 127.0.0.1:16443; proxy exposes on 16444.
YAO_CI_TAI_K8S_HOST=127.0.0.1
YAO_CI_TAI_K8S_GRPC_PORT=19101
YAO_CI_TAI_K8S_HTTP_PORT=8100
YAO_CI_TAI_K8S_VNC_PORT=16081
YAO_CI_TAI_K8S_API_PORT=16444
YAO_CI_K3D_API_PORT=16443
# -- tai-hostexec (TUNNEL mode, no container runtime, HostExec only) --
YAO_CI_TAI_HOSTEXEC_HOST=127.0.0.1
YAO_CI_TAI_HOSTEXEC_GRPC_PORT=19102
YAO_CI_TAI_HOSTEXEC_HTTP_PORT=8101
YAO_CI_TAI_HOSTEXEC_VNC_PORT=16082
# ========================================
# Sandbox V2 addresses (used by test code)
# ========================================
YAO_CI_SANDBOX_LOCAL_ADDR=tai://127.0.0.1:19103
YAO_CI_SANDBOX_DOCKER_ADDR=tai://127.0.0.1:19100
YAO_CI_SANDBOX_K8S_ADDR=tai://127.0.0.1:19101
YAO_CI_SANDBOX_IMAGE=yaoapp/tai-sandbox-test:latest
# ========================================
# HostExec addresses
# ========================================
YAO_CI_HOSTEXEC_LOCAL_ADDR=127.0.0.1:19103
YAO_CI_HOSTEXEC_DOCKER_ADDR=127.0.0.1:19100
YAO_CI_HOSTEXEC_K8S_ADDR=127.0.0.1:19101
YAO_CI_HOSTEXEC_ONLY_ADDR=127.0.0.1:19102
# -- Tunnel --
YAO_CI_TUNNEL=true
# ========================================
# Database (MySQL / PostgreSQL / SQLite)
# ========================================
MYSQL_TEST_HOST=127.0.0.1
MYSQL_TEST_PORT=3308
MYSQL_TEST_USER=test
MYSQL_TEST_PASS=123456
PG_TEST_HOST=127.0.0.1
PG_TEST_PORT=5432
PG_TEST_USER=test
PG_TEST_PASS=123456
SQLITE_DB=./app/db/yao.db
# ========================================
# Legacy variable mapping (migrate later)
# ========================================
TAI_TEST_HOST=127.0.0.1
TAI_TEST_DOCKER=tcp://127.0.0.1:12375
TAI_TEST_GRPC_PORT=19100
TAI_TEST_HTTP_PORT=8099
TAI_TEST_VNC_PORT=16080
TAI_TEST_DOCKER_PORT=12375
TAI_TEST_K8S_HOST=127.0.0.1
TAI_TEST_K8S_PORT=16444
TAI_TEST_K8S_GRPC_PORT=19101
TAI_TEST_K8S_HTTP_PORT=8100
TAI_TEST_K8S_VNC_PORT=16081
TAI_TEST_HOST_IP=172.17.0.1
TAI_TEST_TUNNEL=true
TAI_TEST_YAO_URL=http://127.0.0.1:5099
TAI_TEST_YAO_GRPC=127.0.0.1:9099
SANDBOX_TEST_LOCAL_ADDR=tai://127.0.0.1:19103
SANDBOX_TEST_REMOTE_ADDR=tai://127.0.0.1:19100
SANDBOX_TEST_K8S_REMOTE_ADDR=tai://127.0.0.1:19101
SANDBOX_TEST_HOSTEXEC_ADDR=tai://127.0.0.1:19102
SANDBOX_TEST_IMAGE=yaoapp/tai-sandbox-test:latest
DOCKER_BRIDGE_IP=172.17.0.1

View file

@ -1,48 +0,0 @@
name: Build Linux Artifacts
on:
workflow_dispatch:
inputs:
tags:
description: "Version tags"
jobs:
build:
runs-on: "ubuntu-latest"
container:
image: yaoapp/yao-build:1.0.0
env:
CF_ACCESS_KEY_ID: ${{ secrets.CF_ACCESS_KEY_ID }}
CF_SECRET_ACCESS_KEY: ${{ secrets.CF_SECRET_ACCESS_KEY }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
steps:
- name: Configure R2 For Cloudflare
run: |
aws configure set aws_access_key_id $CF_ACCESS_KEY_ID
aws configure set aws_secret_access_key $CF_SECRET_ACCESS_KEY
aws configure set default.region us-east-1 # Update with your R2 region if different
aws configure set default.s3.signature_version s3v4
aws configure set default.s3.endpoint_url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
aws --version
- name: Build
run: |
export PATH=$PATH:/github/home/go/bin
/app/build.sh
ls -l /data
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: yao-linux
path: |
/data/*
- name: Push To R2 Cloudflare
run: |
for file in /data/*; do
aws s3 cp $file s3://$R2_BUCKET/archives/ --endpoint-url https://$R2_ACCOUNT_ID.r2.cloudflarestorage.com
done

View file

@ -1,182 +0,0 @@
name: Build MacOS Artifacts
on:
workflow_dispatch:
inputs:
tags:
description: "Version tags"
env:
VERSION: 1.0.0
jobs:
build:
strategy:
matrix:
go: ["1.25"]
runs-on: "macos-latest"
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- name: Install pnpm
run: npm install -g pnpm
- name: Setup Cache
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Checkout Kun
uses: actions/checkout@v4
with:
repository: yaoapp/kun
path: kun
- name: Checkout Xun
uses: actions/checkout@v4
with:
repository: yaoapp/xun
path: xun
- name: Checkout Gou
uses: actions/checkout@v4
with:
repository: yaoapp/gou
path: gou
- name: Checkout V8Go
uses: actions/checkout@v4
with:
repository: yaoapp/v8go
path: v8go
- name: Unzip libv8
run: |
files=$(find ./v8go -name "libv8*.zip")
for file in $files; do
dir=$(dirname "$file") # Get the directory where the ZIP file is located
echo "Extracting $file to directory $dir"
unzip -o -d $dir $file
rm -rf $dir/__MACOSX
done
- name: Checkout CUI v1.0
# ** XGEN will be renamed to CUI in the feature. and move to the new repository. **
# ** new repository: https://github.com/YaoApp/cui.git **
uses: actions/checkout@v4
with:
repository: yaoapp/cui
path: cui-v1.0
- name: Checkout Yao-Init
uses: actions/checkout@v4
with:
repository: yaoapp/yao-init
path: yao-init
- name: Move Kun, Xun, Gou, UI, V8Go
run: |
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
mv cui-v1.0 ../
mv yao-init ../
rm -f ../cui-v1.0/packages/setup/vite.config.ts.*
ls -l .
ls -l ../
ls -l ../cui-v1.0/packages/setup/
- name: Checkout Code
uses: actions/checkout@v4
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go }}
- name: Setup Go Tools
run: |
make tools
- name: Get Version
run: |
echo VERSION=$(cat share/const.go |grep 'const VERSION' | awk '{print $4}' | sed "s/\"//g") >> $GITHUB_ENV
- name: Make Artifacts MacOS
run: |
make artifacts-macos
- name: Install Certificates
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
mkdir -p certs
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
security verify-cert -c certs/DeveloperIDG2CA.cer
security verify-cert -c certs/distribution.cer
- name: Import Certificates
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
# create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# import certificate to keychain
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
# import private key to keychain
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Sign Artifacts
run: |
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-arm64
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-amd64
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-arm64-prod
codesign --deep --force --verbose --timestamp --sign "Developer ID Application: ${{ secrets.APPLE_SIGN }}" dist/release/yao-$VERSION-darwin-amd64-prod
- name: Verify Signature
run: |
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-arm64
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-amd64
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-arm64-prod
codesign --verify --deep --strict --verbose=2 dist/release/yao-$VERSION-darwin-amd64-prod
- name: Send to Apple Notary Service
run: |
zip -r dist/release/yao-$VERSION-darwin-arm64.zip dist/release/yao-$VERSION-darwin-arm64
zip -r dist/release/yao-$VERSION-darwin-amd64.zip dist/release/yao-$VERSION-darwin-amd64
zip -r dist/release/yao-$VERSION-darwin-arm64-prod.zip dist/release/yao-$VERSION-darwin-arm64-prod
zip -r dist/release/yao-$VERSION-darwin-amd64-prod.zip dist/release/yao-$VERSION-darwin-amd64-prod
xcrun notarytool submit dist/release/yao-$VERSION-darwin-arm64.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
xcrun notarytool submit dist/release/yao-$VERSION-darwin-amd64.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
xcrun notarytool submit dist/release/yao-$VERSION-darwin-arm64-prod.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
xcrun notarytool submit dist/release/yao-$VERSION-darwin-amd64-prod.zip --apple-id "${{ secrets.APPLE_ID }}" --team-id "${{ secrets.APPLE_TEAME_ID }}" --password "${{ secrets.APPLE_APP_SPEC_PASS }}" --output-format json
rm -f dist/release/yao-$VERSION-darwin-arm64.zip
rm -f dist/release/yao-$VERSION-darwin-amd64.zip
rm -f dist/release/yao-$VERSION-darwin-arm64-prod.zip
rm -f dist/release/yao-$VERSION-darwin-amd64-prod.zip
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: yao-macos
path: |
dist/release/*

View file

@ -1,25 +0,0 @@
name: Create Release
on:
push:
tags:
- "v*"
permissions:
contents: write
jobs:
create:
runs-on: ubuntu-latest
steps:
- name: Create Draft Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${GITHUB_REF#refs/tags/}"
VERSION="${TAG#v}"
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--title "Yao v${VERSION}" \
--generate-notes \
--draft

View file

@ -6,19 +6,19 @@ on:
# paths:
# - ".github/workflows/docker.yml"
workflow_run:
workflows: ["Build Linux Artifacts"]
workflows: ["Release Linux"]
types:
- completed
env:
VERSION: 0.10.5
VERSION: 0.9.0
jobs:
build:
if: ${{ github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v2
- name: Get Version
run: |
@ -28,21 +28,19 @@ jobs:
run: echo $VERSION
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v3
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build Development
uses: docker/build-push-action@v6
env:
DOCKER_CONTENT_TRUST: 1
uses: docker/build-push-action@v2
with:
context: ./docker/development
platforms: linux/amd64
@ -53,9 +51,7 @@ jobs:
tags: yaoapp/yao:${{ env.VERSION }}-amd64-dev
- name: Build Development Arm64
uses: docker/build-push-action@v6
env:
DOCKER_CONTENT_TRUST: 1
uses: docker/build-push-action@v2
with:
context: ./docker/development
platforms: linux/arm64
@ -66,9 +62,7 @@ jobs:
tags: yaoapp/yao:${{ env.VERSION }}-arm64-dev
- name: Build Production
uses: docker/build-push-action@v6
env:
DOCKER_CONTENT_TRUST: 1
uses: docker/build-push-action@v2
with:
context: ./docker/production
platforms: linux/amd64
@ -79,9 +73,7 @@ jobs:
tags: yaoapp/yao:${{ env.VERSION }}-amd64
- name: Build Production Arm64
uses: docker/build-push-action@v6
env:
DOCKER_CONTENT_TRUST: 1
uses: docker/build-push-action@v2
with:
context: ./docker/production
platforms: linux/arm64
@ -90,29 +82,3 @@ jobs:
ARCH=arm64
push: true
tags: yaoapp/yao:${{ env.VERSION }}-arm64
- name: Build Production Slim
uses: docker/build-push-action@v6
env:
DOCKER_CONTENT_TRUST: 1
with:
context: ./docker/production-slim
platforms: linux/amd64
build-args: |
VERSION=${{ env.VERSION }}
ARCH=amd64
push: true
tags: yaoapp/yao:${{ env.VERSION }}-amd64-slim
- name: Build Production Slim Arm64
uses: docker/build-push-action@v6
env:
DOCKER_CONTENT_TRUST: 1
with:
context: ./docker/production-slim
platforms: linux/arm64
build-args: |
VERSION=${{ env.VERSION }}
ARCH=arm64
push: true
tags: yaoapp/yao:${{ env.VERSION }}-arm64-slim

View file

@ -1,211 +0,0 @@
name: Notarize macOS
on:
workflow_run:
workflows: ["Release macOS"]
types: [completed]
workflow_dispatch:
inputs:
version:
description: "Version (auto-detected from latest release if empty)"
required: false
run_id:
description: "Release macOS workflow run ID (auto-detected if empty)"
required: false
permissions:
contents: write
actions: write
concurrency:
group: notarize-${{ github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: true
jobs:
# ===================================================================
# Resolve version + macOS build run_id automatically
# ===================================================================
resolve:
runs-on: ubuntu-latest
if: >
github.event_name == 'workflow_dispatch' ||
(github.event.workflow_run.conclusion == 'success' &&
startsWith(github.event.workflow_run.head_branch, 'v'))
outputs:
version: ${{ steps.resolve.outputs.version }}
run_id: ${{ steps.resolve.outputs.run_id }}
steps:
- name: Resolve version and run_id
id: resolve
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ github.event.inputs.version }}"
RUN_ID="${{ github.event.inputs.run_id }}"
if [ -z "$VERSION" ]; then
TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q '.tagName')
VERSION="${TAG#v}"
fi
if [ -z "$RUN_ID" ]; then
RUN_ID=$(gh run list --repo "$GITHUB_REPOSITORY" \
--workflow="Release macOS" --branch="v${VERSION}" --limit=1 \
--json databaseId,conclusion --jq '.[] | select(.conclusion=="success") | .databaseId')
fi
else
TAG="${{ github.event.workflow_run.head_branch }}"
VERSION="${TAG#v}"
RUN_ID="${{ github.event.workflow_run.id }}"
fi
if [ -z "$VERSION" ] || [ -z "$RUN_ID" ]; then
echo "::error::Failed to resolve version='${VERSION}' run_id='${RUN_ID}'"
exit 1
fi
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "run_id=${RUN_ID}" >> $GITHUB_OUTPUT
echo "Resolved: version=${VERSION} run_id=${RUN_ID}"
# ===================================================================
# Notarize Yao binaries (arm64 + amd64)
# ===================================================================
notarize:
needs: resolve
runs-on: macos-latest
strategy:
matrix:
arch: [arm64, amd64]
steps:
- name: Download Yao Binary
uses: actions/download-artifact@v4
with:
name: yao-darwin-${{ matrix.arch }}
path: bin
run-id: ${{ needs.resolve.outputs.run_id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Install Certificates
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
mkdir -p certs
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
security verify-cert -c certs/DeveloperIDG2CA.cer
security verify-cert -c certs/distribution.cer
- name: Import Certificates
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
security list-keychain -d user -s $KEYCHAIN_PATH
- name: Verify Signature
run: codesign --verify --deep --strict --verbose=2 bin/yao
- name: Notarize Yao ${{ matrix.arch }}
timeout-minutes: 15
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_TEAME_ID: ${{ secrets.APPLE_TEAME_ID }}
APPLE_APP_SPEC_PASS: ${{ secrets.APPLE_APP_SPEC_PASS }}
run: |
zip -j bin/yao.zip bin/yao
SUBMIT_OUT=$(xcrun notarytool submit bin/yao.zip \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAME_ID" \
--password "$APPLE_APP_SPEC_PASS" \
--wait --timeout 10m --output-format json 2>&1) || true
echo "$SUBMIT_OUT"
STATUS=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('status',''))" 2>/dev/null || true)
SUB_ID=$(echo "$SUBMIT_OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" 2>/dev/null || true)
if [ "$STATUS" != "Accepted" ]; then
echo "::error::Yao ${{ matrix.arch }} notarization failed (status: $STATUS)"
[ -n "$SUB_ID" ] && xcrun notarytool log "$SUB_ID" \
--apple-id "$APPLE_ID" \
--team-id "$APPLE_TEAME_ID" \
--password "$APPLE_APP_SPEC_PASS" || true
exit 1
fi
echo "Yao ${{ matrix.arch }} notarization accepted."
# ===================================================================
# After both architectures finish: wait for Linux R2, then trigger CDN
# ===================================================================
finalize:
needs: [resolve, notarize]
runs-on: ubuntu-latest
if: success()
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
steps:
- name: Checkout (for gh CLI context)
uses: actions/checkout@v4
with:
sparse-checkout: .github
- name: Configure AWS CLI
run: |
aws configure set default.region us-east-1
aws configure set default.s3.signature_version s3v4
- name: Wait for all platform assets on R2
run: |
VERSION="${{ needs.resolve.outputs.version }}"
PREFIX="yao/${VERSION}"
PLATFORMS=(
"darwin-arm64"
"darwin-amd64"
"linux-amd64"
"linux-arm64"
)
for ATTEMPT in $(seq 1 30); do
MISSING=0
for P in "${PLATFORMS[@]}"; do
KEY="${PREFIX}/yao-${VERSION}-${P}"
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
MISSING=$((MISSING+1))
fi
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}.sha256" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
MISSING=$((MISSING+1))
fi
done
if [ "$MISSING" -eq 0 ]; then
echo "All 4 platform assets verified on R2."
exit 0
fi
echo "Attempt $ATTEMPT: $MISSING asset(s) still missing, waiting 30s..."
sleep 30
done
echo "::error::Timed out waiting for all platform assets on R2."
exit 1
- name: Trigger CDN latest.json update
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${{ needs.resolve.outputs.version }}"
gh workflow run update-cdn-latest.yml \
-f version="${VERSION}" \
-f mark_latest="true"
echo "Triggered update-cdn-latest.yml for ${VERSION}"

View file

@ -10,15 +10,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v2
- name: Save PR number
run: |
mkdir -p ./pr
echo ${{ github.event.number }} > ./pr/NR
echo ${{ github.event.pull_request.head.sha }} > ./pr/SHA
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v2
with:
name: pr
path: pr/

File diff suppressed because it is too large Load diff

View file

@ -1,245 +1,122 @@
name: Release Linux
on:
workflow_dispatch:
inputs:
tags:
description: "Version tags"
push:
tags:
- "v*"
permissions:
contents: write
env:
IMAGE_NAME: yaoapp/yao
branches: [main]
paths:
- "share/const.go"
jobs:
# ===================================================================
# Build Linux Binaries (amd64 + arm64)
# ===================================================================
build:
runs-on: ubuntu-latest
container:
image: yaoapp/yao-build:1.0.0
steps:
- name: Build
run: |
export PATH=$PATH:/github/home/go/bin
# Clone dependencies
cd /app
git clone https://github.com/yaoapp/kun.git /app/kun
git clone https://github.com/yaoapp/xun.git /app/xun
git clone https://github.com/yaoapp/gou.git /app/gou
git clone https://github.com/yaoapp/v8go.git /app/v8go
git clone https://github.com/yaoapp/cui.git /app/cui-v1.0
git clone https://github.com/yaoapp/yao-init.git /app/yao-init
git clone https://github.com/yaoapp/yao.git /app/yao
# Extract libv8
files=$(find /app/v8go -name "libv8*.zip")
for file in $files; do
dir=$(dirname "$file")
echo "Extracting $file to directory $dir"
unzip -o -d $dir $file
rm -rf $dir/__MACOSX
done
# Set VERSION from git tag (required)
cd /app/yao
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "::error::This workflow requires a tag (refs/tags/v*). Got: $GITHUB_REF"
exit 1
fi
TAG_VERSION="${GITHUB_REF#refs/tags/v}"
echo "Setting VERSION to $TAG_VERSION"
sed -i "s/const VERSION = \".*\"/const VERSION = \"${TAG_VERSION}\"/g" share/const.go
grep 'const VERSION' share/const.go
make tools && make artifacts-linux
mv /app/yao/dist/release/* /data/
ls -l /data
- name: Upload Artifact
uses: actions/upload-artifact@v4
with:
name: yao-linux
path: /data/*
# ===================================================================
# Docker Images (multi-arch manifest: linux/amd64 + linux/arm64)
# ===================================================================
docker:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Get Version
id: version
run: |
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "::error::This workflow requires a tag (refs/tags/v*). Got: $GITHUB_REF"
exit 1
fi
VERSION="${GITHUB_REF#refs/tags/v}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "VERSION=${VERSION}"
- name: Download Linux Artifacts
uses: actions/download-artifact@v4
with:
name: yao-linux
path: artifacts
- name: Prepare Docker Contexts
run: |
VERSION="${{ steps.version.outputs.version }}"
ls -la artifacts/
# Development image uses dev (unstripped) binaries
cp "artifacts/yao-${VERSION}-linux-amd64" docker/development/yao-amd64
cp "artifacts/yao-${VERSION}-linux-arm64" docker/development/yao-arm64
chmod +x docker/development/yao-*
# Production image uses prod (stripped) binaries
cp "artifacts/yao-${VERSION}-linux-amd64-prod" docker/production/yao-amd64
cp "artifacts/yao-${VERSION}-linux-arm64-prod" docker/production/yao-arm64
chmod +x docker/production/yao-*
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USER }}
password: ${{ secrets.DOCKER_TOKEN }}
- name: Build & Push Development (multi-arch)
uses: docker/build-push-action@v6
with:
context: ./docker/development
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-dev
${{ env.IMAGE_NAME }}:dev
- name: Build & Push Production (multi-arch)
uses: docker/build-push-action@v6
with:
context: ./docker/production
platforms: linux/amd64,linux/arm64
push: true
tags: |
${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
${{ env.IMAGE_NAME }}:latest
# ===================================================================
# GitHub Release + R2 Upload (Linux binaries)
# ===================================================================
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
strategy:
matrix:
go: [1.18.2]
runs-on: "ubuntu-latest"
steps:
- name: Get Version
id: version
- name: Arm Build
run: |
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "::error::This workflow requires a tag. Got: $GITHUB_REF"
exit 1
fi
VERSION="${GITHUB_REF#refs/tags/v}"
TAG="${GITHUB_REF#refs/tags/}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tag=${TAG}" >> $GITHUB_OUTPUT
sudo apt-get update
sudo apt-get install -y libc6-armel-cross libc6-dev-armel-cross binutils-arm-linux-gnueabi libncurses5-dev build-essential bison flex libssl-dev bc
sudo apt-get install -y gcc-arm-linux-gnueabi g++-arm-linux-gnueabi
sudo apt-get install -y gcc-arm-linux-gnueabihf g++-arm-linux-gnueabihf
sudo apt-get install -y g++-aarch64-linux-gnu crossbuild-essential-arm64
- name: Download Linux Artifacts
uses: actions/download-artifact@v4
- name: Install coscmd
run: sudo pip3 install coscmd
- name: Setup Cache
uses: actions/cache@v2
with:
name: yao-linux
path: artifacts
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Prepare Release Files
- name: Checkout Kun
uses: actions/checkout@v2
with:
repository: yaoapp/kun
path: kun
- name: Checkout Xun
uses: actions/checkout@v2
with:
repository: yaoapp/xun
path: xun
- name: Checkout Gou
uses: actions/checkout@v2
with:
repository: yaoapp/gou
path: gou
- name: Checkout V8Go
uses: actions/checkout@v2
with:
repository: rogchap/v8go
ref: 5e91d3d9dcabd2986f901b6b31590e49fc3c4dd8
path: v8go
- name: Checkout UI
uses: actions/checkout@v2
with:
repository: yaoapp/xgen
path: ui
- name: Move Kun, Xun, Gou, UI, V8Go
run: |
VERSION="${{ steps.version.outputs.version }}"
mkdir -p release
cp "artifacts/yao-${VERSION}-linux-amd64-prod" "release/yao-${VERSION}-linux-amd64"
cp "artifacts/yao-${VERSION}-linux-arm64-prod" "release/yao-${VERSION}-linux-arm64"
cp "artifacts/yao-${VERSION}-linux-amd64" "release/yao-${VERSION}-linux-amd64-dev"
cp "artifacts/yao-${VERSION}-linux-arm64" "release/yao-${VERSION}-linux-arm64-dev"
chmod +x release/yao-*
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
mv ui ../
ls -l .
ls -l ../
for ARCH in amd64 arm64; do
sha256sum "release/yao-${VERSION}-linux-${ARCH}" | awk '{print $1}' > "release/yao-linux-${ARCH}-prod.sha256"
sha256sum "release/yao-${VERSION}-linux-${ARCH}-dev" | awk '{print $1}' > "release/yao-linux-${ARCH}-dev.sha256"
done
ls -lh release/
- name: Checkout Code
uses: actions/checkout@v2
- name: Wait for Draft Release
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v3
with:
go-version: ${{ matrix.go }}
- name: Setup Go Tools
run: |
make tools
- name: Make Artifacts Linux
run: |
make artifacts-linux
- name: Configure COS For Silicon Valley
env:
GH_TOKEN: ${{ github.token }}
SECRET_ID: ${{ secrets.COS_ID }}
SECRET_KEY: ${{ secrets.COS_KEY }}
BUCKET: release-sv-1252011659
REGION: na-siliconvalley
run: |
TAG="${{ steps.version.outputs.tag }}"
for i in $(seq 1 30); do
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
echo "Draft release found for $TAG."
exit 0
fi
echo "Waiting for draft release... ($i/30)"
sleep 10
done
echo "::error::Timed out waiting for draft release $TAG"
exit 1
coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
- name: Upload Assets to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
- name: Push To Silicon Valley
run: |
TAG="${{ steps.version.outputs.tag }}"
gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber
for file in ./dist/release/*; do coscmd upload $file /archives/; done;
- name: Publish Release if Complete
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')
echo "Current assets: $ASSET_COUNT / 16"
if [ "$ASSET_COUNT" -ge 16 ]; then
echo "All assets present, publishing release..."
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
else
echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish."
fi
# - name: Configure COS For Beijing
# env:
# SECRET_ID: ${{ secrets.COS_ID }}
# SECRET_KEY: ${{ secrets.COS_KEY }}
# BUCKET: release-bj-1252011659
# REGION: ap-beijing
# run: |
# coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
- name: Upload Linux binaries to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
run: |
aws configure set default.region us-east-1
aws configure set default.s3.signature_version s3v4
VERSION="${{ steps.version.outputs.version }}"
PREFIX="yao/${VERSION}"
for PLATFORM in linux-amd64 linux-arm64; do
FILE="release/yao-${VERSION}-${PLATFORM}"
NAME="yao-${VERSION}-${PLATFORM}"
sha256sum "$FILE" | awk '{print $1}' > "/tmp/${NAME}.sha256"
aws s3 cp "$FILE" "s3://${R2_BUCKET}/${PREFIX}/${NAME}" \
--endpoint-url "$R2_ENDPOINTS" \
--content-type "application/octet-stream"
aws s3 cp "/tmp/${NAME}.sha256" "s3://${R2_BUCKET}/${PREFIX}/${NAME}.sha256" \
--endpoint-url "$R2_ENDPOINTS" \
--content-type "text/plain"
echo "Uploaded: ${NAME} + ${NAME}.sha256"
done
# - name: Push To Beijing
# run: |
# for file in ./dist/release/*; do coscmd upload $file /archives/; done;

View file

@ -1,30 +1,27 @@
name: Release macOS
name: Release MacOS
on:
workflow_dispatch:
inputs:
tags:
description: "Version tags"
push:
tags:
- "v*"
permissions:
contents: write
branches: [main]
paths:
- "share/const.go"
jobs:
# ===================================================================
# Build Yao macOS binaries (arm64 + amd64) — one job, both arches
# ===================================================================
build:
runs-on: macos-latest
release:
strategy:
matrix:
go: [1.18.2]
runs-on: "macos-11"
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 18
- name: Install pnpm
run: npm install -g pnpm
- name: Install coscmd
run: sudo pip3 install coscmd
- name: Setup Cache
uses: actions/cache@v4
uses: actions/cache@v2
with:
path: |
~/.cache/go-build
@ -34,311 +31,84 @@ jobs:
${{ runner.os }}-go-
- name: Checkout Kun
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
repository: yaoapp/kun
path: kun
- name: Checkout Xun
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
repository: yaoapp/xun
path: xun
- name: Checkout Gou
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
repository: yaoapp/gou
path: gou
- name: Checkout V8Go
uses: actions/checkout@v4
uses: actions/checkout@v2
with:
repository: yaoapp/v8go
repository: rogchap/v8go
ref: 5e91d3d9dcabd2986f901b6b31590e49fc3c4dd8
path: v8go
- name: Unzip libv8
run: |
files=$(find ./v8go -name "libv8*.zip")
for file in $files; do
dir=$(dirname "$file")
echo "Extracting $file to directory $dir"
unzip -o -d $dir $file
rm -rf $dir/__MACOSX
done
- name: Checkout CUI v1.0
uses: actions/checkout@v4
- name: Checkout UI
uses: actions/checkout@v2
with:
repository: yaoapp/cui
path: cui-v1.0
repository: yaoapp/xgen
path: ui
- name: Checkout Yao-Init
uses: actions/checkout@v4
with:
repository: yaoapp/yao-init
path: yao-init
- name: Move Dependencies
- name: Move Kun, Xun, Gou, UI, V8Go
run: |
mv kun ../
mv xun ../
mv gou ../
mv v8go ../
mv cui-v1.0 ../
mv yao-init ../
rm -f ../cui-v1.0/packages/setup/vite.config.ts.*
mv ui ../
ls -l .
ls -l ../
- name: Checkout Yao
uses: actions/checkout@v4
- name: Checkout Code
uses: actions/checkout@v2
- name: Set Version from Tag
run: |
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "::error::This workflow requires a tag (refs/tags/v*). Got: $GITHUB_REF"
exit 1
fi
TAG="${GITHUB_REF#refs/tags/v}"
echo "Setting VERSION to $TAG"
sed -i.bak "s/const VERSION = \".*\"/const VERSION = \"${TAG}\"/g" share/const.go
rm -f share/const.go.bak
grep 'const VERSION' share/const.go
- name: Setup Go
uses: actions/setup-go@v5
- name: Setup Go ${{ matrix.go }}
uses: actions/setup-go@v3
with:
go-version: "1.25"
go-version: ${{ matrix.go }}
- name: Setup Go Tools
run: make tools
- name: Make Artifacts macOS
run: make artifacts-macos
- name: Get Version
id: version
run: |
VERSION=$(grep 'const VERSION =' share/const.go | awk '{print $4}' | sed 's/"//g')
echo "version=${VERSION}" >> $GITHUB_OUTPUT
make tools
- name: List Build Output
run: ls -lh dist/release/
- name: Make Artifacts MacOS
run: |
make artifacts-macos
- name: Install Certificates
- name: Configure COS For Silicon Valley
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
SECRET_ID: ${{ secrets.COS_ID }}
SECRET_KEY: ${{ secrets.COS_KEY }}
BUCKET: release-sv-1252011659
REGION: na-siliconvalley
run: |
mkdir -p certs
echo "${{ secrets.APPLE_DEVELOPERIDG2CA }}" | base64 --decode > certs/DeveloperIDG2CA.cer
echo "${{ secrets.APPLE_DISTRIBUTION }}" | base64 --decode > certs/distribution.cer
echo "${{ secrets.APPLE_PRIVATE_KEY }}" | base64 --decode > certs/private_key.p12
security verify-cert -c certs/DeveloperIDG2CA.cer
security verify-cert -c certs/distribution.cer
coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
- name: Import Certificates
env:
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
- name: Push To Silicon Valley
run: |
KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security import ./certs/DeveloperIDG2CA.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
security import ./certs/distribution.cer -k $KEYCHAIN_PATH -T /usr/bin/codesign
security import ./certs/private_key.p12 -k $KEYCHAIN_PATH -P "${{ secrets.APPLE_PRIVATE_KEY_PASSWORD }}" -T /usr/bin/codesign
security list-keychain -d user -s $KEYCHAIN_PATH
for file in ./dist/release/*; do coscmd upload $file /archives/; done;
- name: Sign Yao Binaries
run: |
VERSION="${{ steps.version.outputs.version }}"
IDENTITY="Developer ID Application: ${{ secrets.APPLE_SIGN }}"
for ARCH in arm64 amd64; do
for SUFFIX in "" "-prod"; do
BIN="dist/release/yao-${VERSION}-darwin-${ARCH}${SUFFIX}"
codesign --force --verbose --timestamp --options runtime \
--entitlements .github/codesign/entitlements.plist \
--sign "$IDENTITY" "$BIN"
codesign --verify --deep --strict --verbose=2 "$BIN"
done
done
# - name: Configure COS For Beijing
# env:
# SECRET_ID: ${{ secrets.COS_ID }}
# SECRET_KEY: ${{ secrets.COS_KEY }}
# BUCKET: release-bj-1252011659
# REGION: ap-beijing
# run: |
# coscmd config -a $SECRET_ID -s $SECRET_KEY -b $BUCKET -r $REGION
- name: Prepare Output and Checksums
run: |
VERSION="${{ steps.version.outputs.version }}"
for ARCH in arm64 amd64; do
for VARIANT in dev prod; do
if [ "$VARIANT" = "dev" ]; then
SRC="dist/release/yao-${VERSION}-darwin-${ARCH}"
else
SRC="dist/release/yao-${VERSION}-darwin-${ARCH}-prod"
fi
DIR="/tmp/yao-output-${ARCH}-${VARIANT}"
mkdir -p "$DIR"
cp "$SRC" "$DIR/yao"
chmod +x "$DIR/yao"
done
done
mkdir -p /tmp/checksums
for ARCH in arm64 amd64; do
for VARIANT in dev prod; do
shasum -a 256 "/tmp/yao-output-${ARCH}-${VARIANT}/yao" | awk '{print $1" yao"}' > "/tmp/checksums/yao-darwin-${ARCH}-${VARIANT}.sha256"
done
done
cat /tmp/checksums/*.sha256
- name: Upload Artifacts
uses: actions/upload-artifact@v4
with:
name: yao-darwin-arm64
path: /tmp/yao-output-arm64-prod/yao
- name: Upload arm64 Dev Binary
uses: actions/upload-artifact@v4
with:
name: yao-darwin-arm64-dev
path: /tmp/yao-output-arm64-dev/yao
- name: Upload amd64 Binary
uses: actions/upload-artifact@v4
with:
name: yao-darwin-amd64
path: /tmp/yao-output-amd64-prod/yao
- name: Upload amd64 Dev Binary
uses: actions/upload-artifact@v4
with:
name: yao-darwin-amd64-dev
path: /tmp/yao-output-amd64-dev/yao
- name: Upload Checksums
uses: actions/upload-artifact@v4
with:
name: yao-darwin-checksums
path: /tmp/checksums/*.sha256
# ===================================================================
# GitHub Release + R2 Upload (macOS binaries)
# ===================================================================
release:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- name: Get Version
id: version
run: |
if [[ "$GITHUB_REF" != refs/tags/v* ]]; then
echo "::error::This workflow requires a tag. Got: $GITHUB_REF"
exit 1
fi
VERSION="${GITHUB_REF#refs/tags/v}"
TAG="${GITHUB_REF#refs/tags/}"
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "tag=${TAG}" >> $GITHUB_OUTPUT
- name: Download macOS Artifacts
uses: actions/download-artifact@v4
with:
name: yao-darwin-arm64
path: artifacts/arm64-prod
- name: Download arm64 Dev
uses: actions/download-artifact@v4
with:
name: yao-darwin-arm64-dev
path: artifacts/arm64-dev
- name: Download amd64 Prod
uses: actions/download-artifact@v4
with:
name: yao-darwin-amd64
path: artifacts/amd64-prod
- name: Download amd64 Dev
uses: actions/download-artifact@v4
with:
name: yao-darwin-amd64-dev
path: artifacts/amd64-dev
- name: Download Checksums
uses: actions/download-artifact@v4
with:
name: yao-darwin-checksums
path: artifacts/checksums
- name: Prepare Release Files
run: |
VERSION="${{ steps.version.outputs.version }}"
mkdir -p release
cp artifacts/arm64-prod/yao "release/yao-${VERSION}-darwin-arm64"
cp artifacts/amd64-prod/yao "release/yao-${VERSION}-darwin-amd64"
cp artifacts/arm64-dev/yao "release/yao-${VERSION}-darwin-arm64-dev"
cp artifacts/amd64-dev/yao "release/yao-${VERSION}-darwin-amd64-dev"
cp artifacts/checksums/*.sha256 release/ 2>/dev/null || true
chmod +x release/yao-*
ls -lh release/
- name: Wait for Draft Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
for i in $(seq 1 30); do
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" &>/dev/null; then
echo "Draft release found for $TAG."
exit 0
fi
echo "Waiting for draft release... ($i/30)"
sleep 10
done
echo "::error::Timed out waiting for draft release $TAG"
exit 1
- name: Upload Assets to GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
gh release upload "$TAG" release/* --repo "$GITHUB_REPOSITORY" --clobber
- name: Publish Release if Complete
env:
GH_TOKEN: ${{ github.token }}
run: |
TAG="${{ steps.version.outputs.tag }}"
ASSET_COUNT=$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" --json assets --jq '.assets | length')
echo "Current assets: $ASSET_COUNT / 16"
if [ "$ASSET_COUNT" -ge 16 ]; then
echo "All assets present, publishing release..."
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --draft=false --latest
else
echo "Assets incomplete ($ASSET_COUNT/16), waiting for other workflow to publish."
fi
- name: Upload macOS binaries to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
run: |
aws configure set default.region us-east-1
aws configure set default.s3.signature_version s3v4
VERSION="${{ steps.version.outputs.version }}"
PREFIX="yao/${VERSION}"
for PLATFORM in darwin-arm64 darwin-amd64; do
FILE="release/yao-${VERSION}-${PLATFORM}"
NAME="yao-${VERSION}-${PLATFORM}"
sha256sum "$FILE" | awk '{print $1}' > "/tmp/${NAME}.sha256"
aws s3 cp "$FILE" "s3://${R2_BUCKET}/${PREFIX}/${NAME}" \
--endpoint-url "$R2_ENDPOINTS" \
--content-type "application/octet-stream"
aws s3 cp "/tmp/${NAME}.sha256" "s3://${R2_BUCKET}/${PREFIX}/${NAME}.sha256" \
--endpoint-url "$R2_ENDPOINTS" \
--content-type "text/plain"
echo "Uploaded: ${NAME} + ${NAME}.sha256"
done
# - name: Push To Beijing
# run: |
# for file in ./dist/release/*; do coscmd upload $file /archives/; done;

View file

@ -1,534 +0,0 @@
name: Unit Test V1
on:
workflow_dispatch:
inputs:
tags:
description: "Version"
env:
CI_VERSION: "1.0.0"
REPO_KUN: ${{ github.repository_owner }}/kun
REPO_XUN: ${{ github.repository_owner }}/xun
REPO_GOU: ${{ github.repository_owner }}/gou
YAO_DEV: ${{ github.WORKSPACE }}
YAO_ENV: development
YAO_ROOT: ${{ github.WORKSPACE }}/../app
YAO_HOST: 0.0.0.0
YAO_PORT: 5099
YAO_SESSION: "memory"
YAO_LOG: "./logs/application.log"
YAO_LOG_MODE: "TEXT"
YAO_JWT_SECRET: "bLp@bi!oqo-2U+hoTRUG"
YAO_DB_AESKEY: "ZLX=T&f6refeCh-ro*r@"
YAO_EXTENSION_ROOT: ${{ github.WORKSPACE }}/../extension
YAO_TEST_APPLICATION: ${{ github.WORKSPACE }}/../app
YAO_RUNTIME_MIN: 3
YAO_RUNTIME_MAX: 6
YAO_RUNTIME_HEAP_LIMIT: 1500000000
YAO_RUNTIME_HEAP_RELEASE: 10000000
YAO_RUNTIME_HEAP_AVAILABLE: 550000000
YAO_RUNTIME_PRECOMPILE: true
MYSQL_TEST_HOST: "127.0.0.1"
MYSQL_TEST_PORT: "3308"
MYSQL_TEST_USER: "test"
MYSQL_TEST_PASS: "123456"
REDIS_TEST_HOST: "127.0.0.1"
REDIS_TEST_PORT: "6379"
REDIS_TEST_DB: "2"
MONGO_TEST_HOST: "127.0.0.1"
MONGO_TEST_PORT: "27017"
MONGO_TEST_USER: "root"
MONGO_TEST_PASS: "123456"
PG_TEST_HOST: "127.0.0.1"
PG_TEST_PORT: "5432"
PG_TEST_USER: "test"
PG_TEST_PASS: "123456"
jobs:
# =============================================================================
# Environment Setup & Verification
# Build Yao, start services, connect Tai via gRPC tunnel, verify everything.
# No tests are run — this job validates the CI environment is healthy.
# =============================================================================
setup-and-verify:
runs-on: ubuntu-latest
services:
mongodb:
image: mongo:6.0
ports:
- 27017:27017
env:
MONGO_INITDB_ROOT_USERNAME: root
MONGO_INITDB_ROOT_PASSWORD: 123456
MONGO_INITDB_DATABASE: test
postgres:
image: postgres:14
ports:
- 5432:5432
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: 123456
POSTGRES_DB: test
options: >-
--health-cmd="pg_isready -U test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
strategy:
matrix:
go: ["1.25"]
steps:
# ==== Phase 1: Checkout & Setup ====
- name: Checkout Yao
uses: actions/checkout@v4
- name: Setup Build Environment
uses: ./.github/actions/setup-yao
with:
repo-kun: ${{ env.REPO_KUN }}
repo-xun: ${{ env.REPO_XUN }}
repo-gou: ${{ env.REPO_GOU }}
checkout-init: "true"
apple-private-key: ${{ secrets.APPLE_PRIVATE_KEY_USER }}
- name: Load sandbox-v2 env
run: grep -vE '^\s*#|^\s*$' .github/env/sandbox-v2.env >> $GITHUB_ENV
- name: Setup SQLite
run: |
mkdir -p ${{ github.WORKSPACE }}/../app/db
echo "YAO_DB_PRIMARY=${{ github.WORKSPACE }}/../app/db/yao.db" >> $GITHUB_ENV
- name: Start MySQL 8.0
run: |
docker run -d --name mysql \
-e MYSQL_RANDOM_ROOT_PASSWORD=true \
-e MYSQL_USER=${MYSQL_TEST_USER} \
-e MYSQL_PASSWORD=${MYSQL_TEST_PASS} \
-e MYSQL_DATABASE=test \
-p ${MYSQL_TEST_PORT}:3306 \
mysql:8.0 --port=3306 --sql-mode='' \
--character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
for i in $(seq 1 30); do
if docker exec mysql mysqladmin ping -h 127.0.0.1 -u ${MYSQL_TEST_USER} -p${MYSQL_TEST_PASS} > /dev/null 2>&1; then
echo "MySQL ready"
break
fi
echo "Waiting for MySQL... ($i/30)"
sleep 2
done
- name: Start Redis
run: docker run --name redis -d -p 6379:6379 redis:6
# ==== Phase 2: Code Quality & Build ====
- name: Code Quality Check
run: |
make vet
make fmt-check
- name: Build Yao
run: go build -v -o $RUNNER_TEMP/yao .
- name: Build ci-token
run: go build -tags ci -v -o $RUNNER_TEMP/ci-token ./cmd/ci-token
- name: Extract tai binary from image
run: |
CID=$(docker create yaoapp/tai:latest)
docker cp "$CID":/usr/local/bin/tai $RUNNER_TEMP/tai
docker rm "$CID"
chmod +x $RUNNER_TEMP/tai
$RUNNER_TEMP/tai version || echo "tai binary extracted"
# ==== Phase 3: Prepare & Start Yao ====
- name: Prepare test app directory
run: |
cp -r ${{ github.WORKSPACE }}/../yao-init $RUNNER_TEMP/yao-test-app
mkdir -p $RUNNER_TEMP/yao-test-app/db
- name: Start Yao server
run: |
cd $RUNNER_TEMP/yao-test-app
YAO_ROOT=$(pwd) \
YAO_HOST=0.0.0.0 \
YAO_PORT=5099 \
YAO_GRPC_HOST=0.0.0.0 \
YAO_GRPC_PORT=9099 \
YAO_DB_DRIVER=sqlite3 \
YAO_DB_PRIMARY=$(pwd)/db/yao.db \
YAO_SESSION=memory \
YAO_ENV=development \
YAO_JWT_SECRET="${{ env.YAO_JWT_SECRET }}" \
YAO_DB_AESKEY="${{ env.YAO_DB_AESKEY }}" \
$RUNNER_TEMP/yao start &
# Wait for Yao HTTP to be ready (up to 120s)
for i in $(seq 1 60); do
if curl -sf http://127.0.0.1:5099/.well-known/yao > /dev/null 2>&1; then
echo "Yao HTTP ready"
curl -s http://127.0.0.1:5099/.well-known/yao | jq .
break
fi
echo "Waiting for Yao... ($i/60)"
sleep 2
done
curl -sf http://127.0.0.1:5099/.well-known/yao > /dev/null 2>&1 || {
echo "::error::Yao HTTP failed to start"
exit 1
}
# ==== Phase 4: Generate Tai credentials ====
- name: Generate Tai credentials
run: |
gen_cred() {
local CID=$1 TID=$2 OUT=$3
local TOKEN
TOKEN=$($RUNNER_TEMP/ci-token \
--app $RUNNER_TEMP/yao-test-app \
--client-id "$CID" \
--subject "${YAO_CI_OAUTH_SUBJECT:-ci-tai}" \
--user-id "${YAO_CI_OAUTH_USER_ID}" \
--team-id "${YAO_CI_OAUTH_TEAM_ID}" \
--scope "${YAO_CI_OAUTH_SCOPE:-tai:tunnel}" \
--ttl "${YAO_CI_OAUTH_TTL:-24h}" 2>/dev/null | tail -1 | tr -d '[:space:]')
local JSON="{\"client_id\":\"$CID\",\"tai_id\":\"$TID\",\"machine_id\":\"ci-runner\",\"server\":\"http://127.0.0.1:${YAO_CI_HTTP_PORT}\",\"yao_grpc_addr\":\"127.0.0.1:${YAO_CI_GRPC_PORT}\",\"access_token\":\"$TOKEN\",\"scope\":\"${YAO_CI_OAUTH_SCOPE}\",\"expires_at\":\"2099-01-01T00:00:00Z\",\"registered\":true}"
echo -n "$JSON" | base64 -w0 > "$OUT"
echo ""
echo "Credentials JSON (debug): $JSON" | head -c 200
echo "..."
echo "Generated credentials for $CID → $OUT"
}
gen_cred tai-ci-local tai-local-001 $RUNNER_TEMP/tai-local-credentials
gen_cred tai-ci-docker tai-docker-001 $RUNNER_TEMP/tai-docker-credentials
gen_cred tai-ci-k8s tai-k8s-001 $RUNNER_TEMP/tai-k8s-credentials
gen_cred tai-ci-hostexec tai-hostexec-001 $RUNNER_TEMP/tai-hostexec-credentials
# ==== Phase 5: Pull images & Setup K8s ====
- name: Pull test images
run: |
docker pull yaoapp/tai-sandbox-test:latest || true
docker pull yaoapp/tai:latest
docker pull alpine:latest
- name: Install k3d & create cluster
run: |
curl -s https://raw.githubusercontent.com/k3d-io/k3d/main/install.sh | bash
k3d cluster create tai-test --no-lb --wait --api-port ${YAO_CI_K3D_API_PORT}
kubectl wait --for=condition=Ready node --all --timeout=60s
k3d image import alpine:latest -c tai-test
- name: Generate kubeconfig
run: |
K3D_IP=$(docker inspect k3d-tai-test-server-0 | jq -r '.[0].NetworkSettings.Networks["k3d-tai-test"].IPAddress')
echo "k3d server IP: ${K3D_IP}"
k3d kubeconfig get tai-test > /tmp/kubeconfig-k3d.yml
# For tai-k8s container (uses k3d internal IP)
sed "s|server: .*|server: https://${K3D_IP}:6443|" /tmp/kubeconfig-k3d.yml \
> /tmp/kubeconfig-tai-k8s.yml
echo "Container kubeconfig server:"
grep server: /tmp/kubeconfig-tai-k8s.yml
# For test runner (uses localhost via k3d port-mapped API)
sed "s|server: .*|server: https://127.0.0.1:${YAO_CI_K3D_API_PORT}|" /tmp/kubeconfig-k3d.yml \
> $RUNNER_TEMP/kubeconfig-tai.yml
echo "Test runner kubeconfig server:"
grep server: $RUNNER_TEMP/kubeconfig-tai.yml
# Export for later steps
echo "TAI_TEST_KUBECONFIG=$RUNNER_TEMP/kubeconfig-tai.yml" >> $GITHUB_ENV
echo "YAO_CI_TAI_KUBECONFIG=$RUNNER_TEMP/kubeconfig-tai.yml" >> $GITHUB_ENV
# ==== Phase 6: Start Tai instances (host processes) ====
- name: Start tai-local (DIRECT mode, auto-detect Docker)
run: |
mkdir -p $RUNNER_TEMP/tai-local-data
TAI_CREDENTIALS=$RUNNER_TEMP/tai-local-credentials \
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
TAI_DATA_DIR=$RUNNER_TEMP/tai-local-data \
$RUNNER_TEMP/tai server \
--grpc 127.0.0.1:${YAO_CI_TAI_LOCAL_GRPC_PORT} \
--http 127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT} \
--vnc 127.0.0.1:${YAO_CI_TAI_LOCAL_VNC_PORT} \
--docker 127.0.0.1:${YAO_CI_TAI_LOCAL_DOCKER_PORT} \
--direct \
--host-exec --host-exec-full-access \
--log-level debug &
echo $! > $RUNNER_TEMP/tai-local.pid
echo "tai-local PID: $(cat $RUNNER_TEMP/tai-local.pid)"
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT}/healthz > /dev/null 2>&1; then
echo "tai-local HTTP ready"
break
fi
echo "Waiting for tai-local HTTP... ($i/30)"
sleep 1
done
- name: Start tai-docker (TUNNEL mode, Docker API proxy)
run: |
mkdir -p $RUNNER_TEMP/tai-docker-data
TAI_CREDENTIALS=$RUNNER_TEMP/tai-docker-credentials \
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
TAI_DATA_DIR=$RUNNER_TEMP/tai-docker-data \
$RUNNER_TEMP/tai server \
--grpc 127.0.0.1:${YAO_CI_TAI_DOCKER_GRPC_PORT} \
--http 127.0.0.1:${YAO_CI_TAI_DOCKER_HTTP_PORT} \
--vnc 127.0.0.1:${YAO_CI_TAI_DOCKER_VNC_PORT} \
--docker 127.0.0.1:${YAO_CI_TAI_DOCKER_API_PORT} \
--host-exec --host-exec-full-access \
--log-level debug &
echo $! > $RUNNER_TEMP/tai-docker.pid
echo "tai-docker PID: $(cat $RUNNER_TEMP/tai-docker.pid)"
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:${YAO_CI_TAI_DOCKER_HTTP_PORT}/healthz > /dev/null 2>&1; then
echo "tai-docker HTTP ready"
break
fi
echo "Waiting for tai-docker HTTP... ($i/30)"
sleep 1
done
- name: Start tai-k8s (TUNNEL mode, K8s API proxy)
run: |
mkdir -p $RUNNER_TEMP/tai-k8s-data
TAI_CREDENTIALS=$RUNNER_TEMP/tai-k8s-credentials \
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
TAI_DATA_DIR=$RUNNER_TEMP/tai-k8s-data \
TAI_K8S_UPSTREAM="tcp://127.0.0.1:${YAO_CI_K3D_API_PORT}" \
TAI_KUBECONFIG=$RUNNER_TEMP/kubeconfig-tai.yml \
$RUNNER_TEMP/tai server \
--grpc 127.0.0.1:${YAO_CI_TAI_K8S_GRPC_PORT} \
--http 127.0.0.1:${YAO_CI_TAI_K8S_HTTP_PORT} \
--vnc 127.0.0.1:${YAO_CI_TAI_K8S_VNC_PORT} \
--k8s 127.0.0.1:${YAO_CI_TAI_K8S_API_PORT} \
--docker="" \
--host-exec --host-exec-full-access \
--log-level debug &
echo $! > $RUNNER_TEMP/tai-k8s.pid
echo "tai-k8s PID: $(cat $RUNNER_TEMP/tai-k8s.pid)"
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:${YAO_CI_TAI_K8S_HTTP_PORT}/healthz > /dev/null 2>&1; then
echo "tai-k8s HTTP ready"
break
fi
echo "Waiting for tai-k8s HTTP... ($i/30)"
sleep 1
done
- name: Start tai-hostexec (TUNNEL mode, HostExec only, no runtime)
run: |
mkdir -p $RUNNER_TEMP/tai-hostexec-data
TAI_CREDENTIALS=$RUNNER_TEMP/tai-hostexec-credentials \
TAI_YAO_SERVER=http://127.0.0.1:${YAO_CI_HTTP_PORT} \
TAI_DATA_DIR=$RUNNER_TEMP/tai-hostexec-data \
$RUNNER_TEMP/tai server \
--grpc 127.0.0.1:${YAO_CI_TAI_HOSTEXEC_GRPC_PORT} \
--http 127.0.0.1:${YAO_CI_TAI_HOSTEXEC_HTTP_PORT} \
--vnc 127.0.0.1:${YAO_CI_TAI_HOSTEXEC_VNC_PORT} \
--docker="" \
--host-exec --host-exec-full-access \
--log-level debug &
echo $! > $RUNNER_TEMP/tai-hostexec.pid
echo "tai-hostexec PID: $(cat $RUNNER_TEMP/tai-hostexec.pid)"
for i in $(seq 1 30); do
if curl -sf http://127.0.0.1:${YAO_CI_TAI_HOSTEXEC_HTTP_PORT}/healthz > /dev/null 2>&1; then
echo "tai-hostexec HTTP ready"
break
fi
echo "Waiting for tai-hostexec HTTP... ($i/30)"
sleep 1
done
# ==== Phase 7: Environment Verification (fail fast) ====
- name: Verify Environment
run: |
echo "CI Environment v${CI_VERSION}"
echo ""
FAILED=0
check() {
local name=$1; shift
if "$@" > /dev/null 2>&1; then
echo " [PASS] $name"
else
echo " [FAIL] $name"
FAILED=$((FAILED + 1))
fi
}
echo "=== Environment Verification ==="
# ── 1. Service Health ──
echo ""
echo "--- 1. Service Health ---"
echo "[Yao]"
check "Yao HTTP (/.well-known/yao)" curl -sf http://127.0.0.1:5099/.well-known/yao
check "Yao gRPC port" nc -z 127.0.0.1 9099
echo "[tai-local (DIRECT)]"
check "tai-local process alive" kill -0 $(cat $RUNNER_TEMP/tai-local.pid 2>/dev/null || echo 0)
check "tai-local HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT}/healthz
check "tai-local gRPC reachable (direct)" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_GRPC_PORT}
echo "[tai-docker (TUNNEL)]"
check "tai-docker process alive" kill -0 $(cat $RUNNER_TEMP/tai-docker.pid 2>/dev/null || echo 0)
check "tai-docker HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_DOCKER_HTTP_PORT}/healthz
check "tai-docker gRPC listener" nc -z 127.0.0.1 ${YAO_CI_TAI_DOCKER_GRPC_PORT}
echo "[tai-k8s (TUNNEL)]"
check "tai-k8s process alive" kill -0 $(cat $RUNNER_TEMP/tai-k8s.pid 2>/dev/null || echo 0)
check "tai-k8s HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_K8S_HTTP_PORT}/healthz
check "tai-k8s gRPC listener" nc -z 127.0.0.1 ${YAO_CI_TAI_K8S_GRPC_PORT}
echo "[tai-hostexec (TUNNEL)]"
check "tai-hostexec process alive" kill -0 $(cat $RUNNER_TEMP/tai-hostexec.pid 2>/dev/null || echo 0)
check "tai-hostexec HTTP (/healthz)" curl -sf http://127.0.0.1:${YAO_CI_TAI_HOSTEXEC_HTTP_PORT}/healthz
check "tai-hostexec gRPC listener" nc -z 127.0.0.1 ${YAO_CI_TAI_HOSTEXEC_GRPC_PORT}
echo "[K8s (k3d via direct API)]"
check "kubectl get nodes (k3d direct)" kubectl --kubeconfig=$RUNNER_TEMP/kubeconfig-tai.yml get nodes
echo "[Data Stores]"
MONGO_CID=$(docker ps -qf "ancestor=mongo:6.0" | head -1)
check "MongoDB ping" docker exec "$MONGO_CID" mongosh --quiet \
-u ${MONGO_TEST_USER} -p ${MONGO_TEST_PASS} --authenticationDatabase admin \
--eval "db.runCommand({ping:1})"
check "MySQL ping" docker exec mysql mysqladmin ping -h 127.0.0.1 \
-u ${MYSQL_TEST_USER} -p${MYSQL_TEST_PASS}
PG_CID=$(docker ps -qf "ancestor=postgres:14" | head -1)
check "PostgreSQL ping" docker exec "$PG_CID" pg_isready -U ${PG_TEST_USER}
check "Redis ping" docker exec redis redis-cli ping
# ── 2. Network Topology ──
echo ""
echo "--- 2. Network Topology ---"
BRIDGE_IP=${YAO_CI_BRIDGE_IP}
echo "[Host network basics]"
check "docker0 bridge exists" ip addr show docker0
check "Bridge IP reachable (${BRIDGE_IP})" ping -c1 -W2 ${BRIDGE_IP}
check "Docker socket accessible" test -S /var/run/docker.sock
check "tai binary on runner" test -x $RUNNER_TEMP/tai
echo "[Yao endpoints (all Tai instances need these)]"
check "Yao HTTP :${YAO_CI_HTTP_PORT}" curl -sf http://127.0.0.1:${YAO_CI_HTTP_PORT}/.well-known/yao
check "Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
echo "[DIRECT path: Yao → tai-local]"
check "Yao→tai-local gRPC :${YAO_CI_TAI_LOCAL_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_GRPC_PORT}
check "Yao→tai-local HTTP :${YAO_CI_TAI_LOCAL_HTTP_PORT}" curl -sf http://127.0.0.1:${YAO_CI_TAI_LOCAL_HTTP_PORT}/healthz
check "tai-local Docker API proxy :${YAO_CI_TAI_LOCAL_DOCKER_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_DOCKER_PORT}
echo "[TUNNEL path: tai-docker → Yao gRPC (reverse tunnel)]"
check "tai-docker→Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
check "tai-docker Docker API proxy :${YAO_CI_TAI_DOCKER_API_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_DOCKER_API_PORT}
check "Docker API via proxy" bash -c "curl -sf http://127.0.0.1:${YAO_CI_TAI_DOCKER_API_PORT}/version | jq -r .ApiVersion"
echo "[TUNNEL path: tai-k8s → Yao gRPC (reverse tunnel)]"
check "tai-k8s→Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
check "tai-k8s K8s API proxy :${YAO_CI_TAI_K8S_API_PORT}" nc -z 127.0.0.1 ${YAO_CI_TAI_K8S_API_PORT}
sed "s|server: .*|server: https://127.0.0.1:${YAO_CI_TAI_K8S_API_PORT}|" $RUNNER_TEMP/kubeconfig-tai.yml \
> $RUNNER_TEMP/kubeconfig-tai-proxy.yml
check "K8s API via tai-k8s proxy (kubectl)" kubectl --kubeconfig=$RUNNER_TEMP/kubeconfig-tai-proxy.yml --insecure-skip-tls-verify get nodes
echo "[TUNNEL path: tai-hostexec → Yao gRPC (reverse tunnel)]"
check "tai-hostexec→Yao gRPC :${YAO_CI_GRPC_PORT}" nc -z 127.0.0.1 ${YAO_CI_GRPC_PORT}
echo "[Data Store connectivity from runner]"
check "MongoDB :27017" nc -z 127.0.0.1 27017
check "Redis :6379" nc -z 127.0.0.1 6379
check "MySQL :${MYSQL_TEST_PORT}" nc -z 127.0.0.1 ${MYSQL_TEST_PORT}
check "PostgreSQL :${PG_TEST_PORT}" nc -z 127.0.0.1 ${PG_TEST_PORT}
# ── 3. Connection Mode Verification ──
echo ""
echo "--- 3. Connection Modes ---"
sleep 5
echo "[Credentials (4 tokens)]"
check "tai-local credentials exist" test -f $RUNNER_TEMP/tai-local-credentials
check "tai-docker credentials exist" test -f $RUNNER_TEMP/tai-docker-credentials
check "tai-k8s credentials exist" test -f $RUNNER_TEMP/tai-k8s-credentials
check "tai-hostexec credentials exist" test -f $RUNNER_TEMP/tai-hostexec-credentials
echo "[DIRECT: tai-local → Yao HTTP register → Yao dials tai-local gRPC]"
echo " tai-local registers via POST /tai-nodes/register"
echo " Yao dials back tai-local gRPC at 127.0.0.1:${YAO_CI_TAI_LOCAL_GRPC_PORT}"
echo "[TUNNEL: tai-docker → Yao gRPC :${YAO_CI_GRPC_PORT} (Register + Forward)]"
echo " Sandbox connects Yao gRPC → Forward stream → tai-docker :${YAO_CI_TAI_DOCKER_GRPC_PORT}"
echo "[TUNNEL: tai-k8s → Yao gRPC :${YAO_CI_GRPC_PORT} (Register + Forward)]"
echo " Sandbox connects Yao gRPC → Forward stream → tai-k8s :${YAO_CI_TAI_K8S_GRPC_PORT}"
echo "[TUNNEL: tai-hostexec → Yao gRPC :${YAO_CI_GRPC_PORT} (Register + Forward)]"
echo " HostExec only, no container runtime"
WELL_KNOWN=$(curl -sf http://127.0.0.1:5099/.well-known/yao 2>/dev/null || echo "{}")
echo ""
echo " Yao .well-known/yao:"
echo "$WELL_KNOWN" | jq . 2>/dev/null || echo " $WELL_KNOWN"
# ── 4. HostExec Readiness ──
echo ""
echo "--- 4. HostExec ---"
echo "[HostExec gRPC ports (all 4 instances)]"
check "HostExec tai-local (direct, auto-Docker)" nc -z 127.0.0.1 ${YAO_CI_TAI_LOCAL_GRPC_PORT}
check "HostExec tai-docker (tunnel, Docker proxy)" nc -z 127.0.0.1 ${YAO_CI_TAI_DOCKER_GRPC_PORT}
check "HostExec tai-k8s (tunnel, K8s proxy)" nc -z 127.0.0.1 ${YAO_CI_TAI_K8S_GRPC_PORT}
check "HostExec tai-hostexec (tunnel, no runtime)" nc -z 127.0.0.1 ${YAO_CI_TAI_HOSTEXEC_GRPC_PORT}
echo ""
echo "=========================================="
if [ $FAILED -gt 0 ]; then
echo "::error::$FAILED verification check(s) FAILED"
echo ""
echo "=== Diagnostic Info ==="
echo "--- Docker containers ---"
docker ps -a
echo ""
echo "--- Processes (yao + tai) ---"
ps aux | grep -E "yao|tai" | grep -v grep || true
echo ""
echo "--- Listening ports ---"
ss -tlnp | grep -E "5099|9099|19100|19101|19102|19103|8099|8100|8101|8102|12375|12376|16443|16444" || true
exit 1
else
echo "All verification checks PASSED"
fi

File diff suppressed because it is too large Load diff

View file

@ -1,121 +0,0 @@
name: Update CDN latest.json
# Assembles yao/latest.json after all platform binaries are on R2.
#
# Normally triggered automatically by notarize-macos.yml's finalize job after
# notarization completes. Can also be triggered manually as a fallback.
#
# Prerequisites: release-linux.yml and release-macos.yml must have uploaded
# all 4 platform binaries to R2.
on:
workflow_dispatch:
inputs:
version:
description: "Engine version to publish (e.g. 1.0.0 or 1.0.0-alpha)"
required: true
mark_latest:
description: "Also update yao/latest.json (set false for pre-releases you want on CDN but not as latest)"
required: false
default: "true"
permissions:
contents: read
jobs:
publish-latest:
runs-on: ubuntu-latest
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
R2_ENDPOINTS: ${{ secrets.R2_ENDPOINTS }}
R2_BUCKET: ${{ secrets.R2_BUCKET || 'releases' }}
CDN_BASE: https://get.yaoapps.com
steps:
- name: Configure AWS CLI
run: |
aws configure set default.region us-east-1
aws configure set default.s3.signature_version s3v4
- name: Verify platform assets exist
run: |
VERSION="${{ github.event.inputs.version }}"
PREFIX="yao/${VERSION}"
PLATFORMS=(
"darwin-arm64"
"darwin-amd64"
"linux-amd64"
"linux-arm64"
)
MISSING=0
for P in "${PLATFORMS[@]}"; do
KEY="${PREFIX}/yao-${VERSION}-${P}"
echo "Checking s3://${R2_BUCKET}/${KEY}"
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
echo "::warning::Missing asset: ${KEY}"
MISSING=$((MISSING+1))
fi
if ! aws s3 ls "s3://${R2_BUCKET}/${KEY}.sha256" --endpoint-url "$R2_ENDPOINTS" >/dev/null 2>&1; then
echo "::warning::Missing sha256: ${KEY}.sha256"
MISSING=$((MISSING+1))
fi
done
if [ "$MISSING" -gt 0 ]; then
echo "::error::$MISSING required asset(s) are missing on R2. Run platform CI workflows first."
exit 1
fi
echo "All platform assets verified."
- name: Build latest.json
run: |
VERSION="${{ github.event.inputs.version }}"
RELEASED_AT="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
python3 <<PY > /tmp/latest.json
import json
version = "${VERSION}"
base = "${CDN_BASE}/yao/${VERSION}"
assets = {
"darwin-arm64": f"{base}/yao-{version}-darwin-arm64",
"darwin-amd64": f"{base}/yao-{version}-darwin-amd64",
"linux-amd64": f"{base}/yao-{version}-linux-amd64",
"linux-arm64": f"{base}/yao-{version}-linux-arm64",
}
sha256 = {
"darwin-arm64": f"{base}/yao-{version}-darwin-arm64.sha256",
"darwin-amd64": f"{base}/yao-{version}-darwin-amd64.sha256",
"linux-amd64": f"{base}/yao-{version}-linux-amd64.sha256",
"linux-arm64": f"{base}/yao-{version}-linux-arm64.sha256",
}
data = {
"version": version,
"released_at": "${RELEASED_AT}",
"assets": assets,
"sha256": sha256,
}
print(json.dumps(data, indent=2, ensure_ascii=False))
PY
cat /tmp/latest.json
- name: Upload versioned latest.json
run: |
VERSION="${{ github.event.inputs.version }}"
aws s3 cp /tmp/latest.json \
"s3://${R2_BUCKET}/yao/${VERSION}/latest.json" \
--endpoint-url "$R2_ENDPOINTS" \
--content-type "application/json" \
--cache-control "public, max-age=60"
- name: Promote to yao/latest.json
if: ${{ github.event.inputs.mark_latest != 'false' }}
run: |
aws s3 cp /tmp/latest.json \
"s3://${R2_BUCKET}/yao/latest.json" \
--endpoint-url "$R2_ENDPOINTS" \
--content-type "application/json" \
--cache-control "public, max-age=60"
echo "Promoted to yao/latest.json"

62
.gitignore vendored
View file

@ -11,8 +11,6 @@
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
*.log
# Dependency directories (remove the comment below to include it)
# vendor/
.DS_Store
@ -21,8 +19,7 @@
dist
ui
^ui/index.html
tests/data/*
!tests/data/assets
tests/data/*/*
debug.log
yao-test
dev.sh
@ -30,60 +27,3 @@ logs
logs/application.log
yao-arm64
tests/db/yao.db
env.*.sh
xgen/v0.9/*
xgen/v1.0/*
!xgen/v0.9/index.html
!xgen/v1.0/index.html
!xgen/v1.0/umi.js
!xgen/v1.0/layouts__index.async.js
!pipe/ui
*-unit-test
docker/build/test
db
!agent/search/handlers/db
*.sh
data/bindata.go.bak
share/const.go.bak
share/const.goe
.cursor
openapi/*.md
coverage.html
agent/assistant/hook/*.test.md
agent/search/TODO.md
agent/search/job-logs.txt
agent/test/MULTI_TURN_DESIGN.md
agent/test/UPGRADE_PLAN.md
introduction/*
!sandbox/docker/build.sh
!sandbox/docker/vnc/*.sh
!sandbox/docker/desktop/config/*.sh
sandbox/docker/yao-bridge-*
sandbox/docker/claude-proxy-*
sandbox/docker/claude/claude-proxy-*
sandbox/proxy/claude-proxy-linux-*
release/*
sandbox/TODO-VNC.md
sandbox/docker/chrome/PLAN.md
sandbox/DESIGN-REMOTE.md
event/DESIGN.md
event/TODO.md
agent/robot/DESIGN-V2.md
tg-session.json
tg-login
tg-send
registry/data/
registry/manager/DESIGN*.md
tai/testdata/
agent/sandbox/docs/*.md
tai/docs/refactor-registration.md
agent/robot/ROBOT-WATCHER-IMPROVEMENT.md
agent/robot/ROBOT-IM-INTEGRATION-IMPROVEMENT.md
agent/robot/ROBOT-CACHE-IMPROVEMENT.md
sandbox/v2/PID-KILL-UPGRADE.md
sandbox/v2/*.md
POSTGRESQL_COMPAT.md
openapi/setting/*.md
agent/docs/design/*.md
tools/README.md
tools/TOOL-REGISTRATION.md

209
LICENSE
View file

@ -1,30 +1,201 @@
# Open Source License
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Yao Engine is licensed under a modified version of the Apache License 2.0, with the following additional conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Commercial Usage Terms:
Yao Engine may be utilized commercially, A commercial license from the producer is required if:
1. Definitions.
a. Trademark and Branding Requirements
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
- The Yao Engine / Yao Agents / Tai / Tai Link console/application logo and copyright information must not be removed or modified
- Logo and copyright information can only be changed with an authorization certificate issued through Yao Developer Certificate
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
b. Authorization Verification Requirements
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
- The Yao certificate verification logic, processes, and related pages (marked in code comments) must be preserved
- The complete Yao certificate verification system must be maintained regardless of usage purpose
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
c. Enterprise Scale Requirements
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
- Organizations with 50 or more employees, or with annual revenue exceeding USD 1,000,000, must obtain a commercial license from Infinite Wisdom Software.
- To obtain a commercial license, please contact us at https://yaoagents.com/enterprise
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
2. Contributor Agreement:
As a contributor, you should agree that:
a. Infinite Wisdom Software can adjust the open-source agreement to be more strict or relaxed as deemed necessary.
b. Your contributed code may be used for commercial purposes, including but not limited to its cloud business operations.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
All other rights and restrictions follow the Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
© 2026 Infinite Wisdom Software.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View file

@ -1,30 +0,0 @@
# 开源许可证
Yao Engine 基于修改版 Apache License 2.0 授权,并附加以下额外条款:
1. 商业使用条款:
Yao Engine 可用于商业用途,但在以下情况下须向 Infinite Wisdom Software 获取商业授权许可:
a. 商标与品牌要求
- 不得删除或修改 Yao Engine / Yao Agents / Tai / Tai Link 控制台/应用程序的徽标及版权信息
- 徽标和版权信息仅可在持有通过 Yao 开发者证书颁发的授权证书时方可更改
b. 授权验证要求
- 必须保留 Yao 证书验证逻辑、流程及相关页面(已在代码注释中标注)
- 无论使用目的如何,必须维持完整的 Yao 证书验证系统
c. 企业规模要求
- 员工人数达到 50 人及以上,或年收入超过 100 万美元的企业,须向 Infinite Wisdom Software 购买商业授权许可。
- 如需获取商业授权,请访问 https://yaoagents.com/enterprise 联系我们。
2. 贡献者协议:
作为贡献者,您需同意以下条款:
a. Infinite Wisdom Software 可视需要对本开源协议进行更严格或更宽松的调整。
b. 您贡献的代码可被用于商业用途,包括但不限于云服务业务运营。
其他所有权利与限制遵循 Apache License 2.0http://www.apache.org/licenses/LICENSE-2.0)。
© 2026 Infinite Wisdom Software.

776
Makefile
View file

@ -6,48 +6,21 @@ VETPACKAGES ?= $(shell $(GO) list ./... | grep -v /examples/)
GOFILES := $(shell find . -name "*.go")
VERSION := $(shell grep 'const VERSION =' share/const.go |awk '{print $$4}' |sed 's/\"//g')
COMMIT := $(shell git log | head -n 1 | awk '{print substr($$2, 0, 12)}')
NOW := $(shell date +"%FT%T%z")
OS := $(shell uname)
# ROOT_DIR := $(shell dirname $(realpath $(firstword $(MAKEFILE_LIST))))
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|registry|agent/sandbox/v2' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
# Sandbox setting tests (openapi/tests/setting/sandbox_test.go) require Docker + Tai — skipped in CI, run locally only
# Core tests (exclude AI-related: agent, aigc, openai, KB, sandbox, registry, grpc, and integrations which require external services)
TESTFOLDER_CORE := $(shell $(GO) list ./... | grep -vE 'examples|openai|aigc|neo|twilio|share*|agent|kb|sandbox|integrations|registry|tai|grpc' | awk '!/\/tests\// || /openapi\/tests/' | grep -vE 'openapi/tests/(nodes|sandbox|workspace)')
# Agent tests (agent, aigc) - exclude agent/search/handlers/web (requires external API keys), robot packages (tested in robot job), and agent/sandbox/v2 (WIP, has its own job)
TESTFOLDER_AGENT := $(shell $(GO) list ./agent/... ./aigc/... | grep -vE 'agent/search/handlers/web|agent/robot/|agent/sandbox/v2')
# KB tests (kb)
TESTFOLDER_KB := $(shell $(GO) list ./kb/...)
# Robot tests (agent/robot/... packages, excluding events/integrations which require Telegram etc.)
TESTFOLDER_ROBOT := $(shell $(GO) list ./agent/robot/... | grep -vE 'agent/robot/events')
# Sandbox tests (requires Docker) — excludes sandbox/v2 (has its own job)
TESTFOLDER_SANDBOX := $(shell $(GO) list ./sandbox/... | grep -v 'sandbox/v2')
# Tai SDK tests (requires Tai container with Docker socket)
TESTFOLDER_TAI := $(shell $(GO) list ./tai/...)
# Workspace tests (requires Tai for remote mode)
TESTFOLDER_WORKSPACE := $(shell $(GO) list ./workspace/...)
# gRPC tests
TESTFOLDER_GRPC := $(shell $(GO) list ./grpc/...)
TESTFOLDER := $(shell $(GO) list ./... | grep -vE 'examples|tests*|config')
TESTTAGS ?= ""
# TESTWIDGETS := $(shell $(GO) list ./widgets/...)
# Unit Test (all tests)
.PHONY: unit-test
unit-test:
# Unit Test
.PHONY: test
test:
echo "mode: count" > coverage.out
for d in $(TESTFOLDER); do \
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
@ -64,380 +37,6 @@ unit-test:
fi; \
done
# Core Unit Test (exclude AI-related tests)
.PHONY: unit-test-core
unit-test-core:
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_CORE); do \
$(GO) test -tags $(TESTTAGS) -v -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_|TestSandbox' $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
# Agent Unit Test (agent, aigc) - excludes robot packages (tested in unit-test-robot) and TestE2E*
.PHONY: unit-test-agent
unit-test-agent:
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_AGENT); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=50m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestE2E' $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
# KB Unit Test (kb)
.PHONY: unit-test-kb
unit-test-kb:
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_KB); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=20m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal|TestSearchCleanup' $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
# Robot Test (all agent/robot/... packages) - runs ALL tests (unit + E2E) with real LLM calls
# These tests require: LLM API keys, database, and longer timeout
.PHONY: unit-test-robot
unit-test-robot:
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_ROBOT); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=50m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal' $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
# Registry Client Test (requires Yao Registry service)
.PHONY: unit-test-registry
unit-test-registry:
echo "mode: count" > coverage.out
$(GO) test -v -p 1 -timeout=5m -covermode=count -coverprofile=profile.out ./registry/... > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi
# ---------------------------------------------------------------------------
# Sandbox V2 CI Test (tai SDK + workspace only)
# Full sandbox/v2 integration tests (multi-pool, K8s, etc.) are run locally.
# ---------------------------------------------------------------------------
.PHONY: unit-test-sandbox-v2
unit-test-sandbox-v2: unit-test-tai unit-test-workspace
@echo ""
@echo "============================================="
@echo "All Sandbox V2 CI tests passed (tai + workspace)"
@echo "============================================="
# Workspace Unit Test (requires Tai for remote mode)
.PHONY: unit-test-workspace
unit-test-workspace:
@echo ""
@echo "============================================="
@echo "Running Workspace Tests..."
@echo "============================================="
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_WORKSPACE); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
@echo ""
@echo "============================================="
@echo "All workspace tests passed"
@echo "============================================="
# Sandbox Unit Test (requires Docker)
.PHONY: unit-test-sandbox
unit-test-sandbox:
@echo ""
@echo "============================================="
@echo "Running Sandbox Tests (requires Docker)..."
@echo "============================================="
@echo "Pulling sandbox test images..."
docker pull alpine:latest || true
docker pull yaoapp/sandbox-base:latest || true
docker pull yaoapp/sandbox-claude:latest || true
@echo ""
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_SANDBOX); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=10m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") -skip='TestMemoryLeak|TestIsolateDisposal' $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
@echo ""
@echo "============================================="
@echo "✅ All sandbox tests passed"
@echo "============================================="
# Tai SDK Test (requires Tai container with Docker socket)
.PHONY: unit-test-tai
unit-test-tai:
@echo ""
@echo "============================================="
@echo "Running Tai SDK Tests (requires Tai container)..."
@echo "============================================="
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_TAI); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=5m -covermode=count -coverprofile=profile.out -coverpkg=$$(echo $$d | sed "s/\/test$$//g") $$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
@echo ""
@echo "============================================="
@echo "All Tai SDK tests passed"
@echo "============================================="
# Proto codegen
.PHONY: proto
proto:
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
grpc/pb/yao.proto
# gRPC Unit Test
.PHONY: unit-test-grpc
unit-test-grpc:
echo "mode: count" > coverage.out
for d in $(TESTFOLDER_GRPC); do \
$(GO) test -tags $(TESTTAGS) -v -timeout=10m \
-covermode=count -coverprofile=profile.out \
-coverpkg=$$(echo $$d | sed "s/\/test$$//g") \
-skip='TestMemoryLeak|TestIsolateDisposal|TestLeak_|TestScenario_' \
$$d > tmp.out; \
cat tmp.out; \
if grep -q "^--- FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^FAIL" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "^panic:" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "build failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "setup failed" tmp.out; then \
rm tmp.out; \
exit 1; \
elif grep -q "runtime error" tmp.out; then \
rm tmp.out; \
exit 1; \
fi; \
if [ -f profile.out ]; then \
cat profile.out | grep -v "mode:" >> coverage.out; \
rm profile.out; \
fi; \
done
# Benchmark Test
.PHONY: benchmark
benchmark:
@echo ""
@echo "============================================="
@echo "Running Benchmark Tests (agent, trace, event)..."
@echo "============================================="
@for d in $$($(GO) list ./agent/... ./trace/... ./event/...); do \
if $(GO) test -list=Benchmark $$d 2>/dev/null | grep -q "^Benchmark"; then \
echo ""; \
echo "📊 Benchmarking: $$d"; \
echo "---------------------------------------------"; \
$(GO) test -bench=. -benchmem -benchtime=100x -run='^$$' $$d || true; \
fi; \
done
@echo ""
@echo "============================================="
@echo "✅ All benchmarks completed"
@echo "============================================="
# Memory Leak Detection Test
.PHONY: memory-leak
memory-leak:
@echo ""
@echo "============================================="
@echo "Running Memory Leak Detection (agent, trace, event)..."
@echo "============================================="
@for d in $$($(GO) list ./agent/... ./trace/... ./event/...); do \
if $(GO) test -list='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak|TestLeak_|TestScenario_' $$d 2>/dev/null | grep -qE "^Test(MemoryLeak|IsolateDisposal|GoroutineLeak|Leak_|Scenario_)"; then \
echo ""; \
echo "🔍 Memory Leak Detection: $$d"; \
echo "---------------------------------------------"; \
$(GO) test -run='TestMemoryLeak|TestIsolateDisposal|TestGoroutineLeak|TestLeak_|TestScenario_' -v -timeout=5m $$d || exit 1; \
fi; \
done
@echo ""
@echo "============================================="
@echo "✅ All memory leak tests passed"
@echo "============================================="
# Run all tests (unit + benchmark + memory leak)
.PHONY: test
test: unit-test benchmark memory-leak
.PHONY: fmt
fmt:
$(GOFMT) -w $(GOFILES)
@ -452,6 +51,8 @@ fmt-check:
fi;
vet:
$(GO) env -w GONOPROXY=github.com/yaoapp/gou
$(GO) env -w GOPRIVATE=github.com/yaoapp/gou
$(GO) vet $(VETPACKAGES)
.PHONY: lint
@ -509,88 +110,36 @@ pack: bindata fmt
.PHONY: bindata
bindata:
# Setup Workdir
rm -rf .tmp/data
rm -rf .tmp/yao-init
mkdir -p .tmp/data
# Checkout init
git clone https://github.com/YaoApp/yao-init.git .tmp/yao-init
rm -rf .tmp/yao-init/.git
rm -rf .tmp/yao-init/.gitignore
rm -rf .tmp/yao-init/LICENSE
# rm -rf .tmp/yao-init/README.md
# Copy Files
cp -r .tmp/yao-init .tmp/data/init
cp -r ui .tmp/data/
cp -r ui .tmp/data/public
cp -r cui .tmp/data/
cp -r yao .tmp/data/
cp -r sui/libsui .tmp/data/
find .tmp/data -name ".DS_Store" -type f -delete
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
rm -rf .tmp/data
rm -rf .tmp/yao-init
# make artifacts-linux
.PHONY: artifacts-linux
artifacts-linux: clean
mkdir -p dist/release
# Building CUI v1.0
export NODE_ENV=production
# rm -f ../cui-v1.0/pnpm-lock.yaml
echo "BASE=__yao_admin_root" > ../cui-v1.0/packages/cui/.env
cd ../cui-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
# Init Application
cd ../yao-init && rm -rf .git
cd ../yao-init && rm -rf .gitignore
cd ../yao-init && rm -rf LICENSE
# cd ../yao-init rm -rf README.md
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' ../yao-init/.env
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' ../yao-init/.env
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' ../yao-init/.env
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' ../yao-init/.env
rm -f ../yao-init/.env.bak
# Yao Builder
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
# mkdir -p .tmp/data/builder
# curl -o .tmp/yao-builder-latest.tar.gz https://release-sv.yaoapps.com/archives/yao-builder-latest.tar.gz
# tar -zxvf .tmp/yao-builder-latest.tar.gz -C .tmp/data/builder
# rm -rf .tmp/yao-builder-latest.tar.gz
# Building UI
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" ../ui/public/icon/md_icon.css
cd ../ui && npm install && npm run build
# Packing
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
# ** new repository: https://github.com/YaoApp/cui.git **
mkdir -p .tmp/data/cui
cp -r ./ui .tmp/data/ui
cp -r ../cui-v1.0/packages/cui/dist .tmp/data/cui/v1.0
cp -r ../yao-init .tmp/data/init
mkdir -p .tmp/data
cp -r ../ui/dist .tmp/data/ui
cp -r yao .tmp/data/
cp -r sui/libsui .tmp/data/
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
rm -rf .tmp/data
rm -rf .tmp/ui
# Replace PRVERSION
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
@CUI_COMMIT=$$(cd ../cui-v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}\"/g" share/const.go
# Making artifacts - dev builds (full debug symbols, ~158M)
# Making artifacts
mkdir -p dist
CGO_ENABLED=1 CGO_LDFLAGS="-static" GOOS=linux GOARCH=amd64 go build -v -o dist/yao-${VERSION}-linux-amd64
CGO_ENABLED=1 CGO_LDFLAGS="-static" LD_LIBRARY_PATH=/usr/lib/gcc-cross/aarch64-linux-gnu/13 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc-13 CXX=aarch64-linux-gnu-g++-13 go build -v -o dist/yao-${VERSION}-linux-arm64
# Making artifacts - prod builds (stripped, ~111M)
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp
CGO_ENABLED=1 CGO_LDFLAGS="-static" GOOS=linux GOARCH=amd64 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-linux-amd64-prod
CGO_ENABLED=1 CGO_LDFLAGS="-static" LD_LIBRARY_PATH=/usr/lib/gcc-cross/aarch64-linux-gnu/13 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc-13 CXX=aarch64-linux-gnu-g++-13 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-linux-arm64-prod
CGO_ENABLED=1 CGO_LDFLAGS="-static" GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc CXX=aarch64-linux-gnu-g++ go build -v -o dist/yao-${VERSION}-linux-arm64
mkdir -p dist/release
mv dist/yao-*-* dist/release/
@ -605,74 +154,52 @@ artifacts-linux: clean
# make artifacts-macos
.PHONY: artifacts-macos
artifacts-macos: clean
mkdir -p dist/release
# Building CUI v1.0
export NODE_ENV=production
# rm -f ../cui-v1.0/pnpm-lock.yaml
echo "BASE=__yao_admin_root" > ../cui-v1.0/packages/cui/.env
cd ../cui-v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
# Init Application
cd ../yao-init && rm -rf .git
cd ../yao-init && rm -rf .gitignore
cd ../yao-init && rm -rf LICENSE
# cd ../yao-init && rm -rf README.md
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' ../yao-init/.env
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' ../yao-init/.env
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' ../yao-init/.env
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' ../yao-init/.env
rm -f ../yao-init/.env.bak
# Building UI
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" ../ui/public/icon/md_icon.css
cd ../ui && npm install && npm run build
# Packing
mkdir -p .tmp/data/cui
cp -r ./ui .tmp/data/ui
cp -r ../cui-v1.0/packages/cui/dist .tmp/data/cui/v1.0
cp -r ../yao-init .tmp/data/init
mkdir -p .tmp/data
cp -r ../ui/dist .tmp/data/ui
cp -r yao .tmp/data/
cp -r sui/libsui .tmp/data/
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
rm -rf .tmp/data
rm -rf .tmp/ui
# Replace PRVERSION
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
@CUI_COMMIT=$$(cd ../cui-v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}\"/g" share/const.go
# Making artifacts - dev builds (full debug symbols)
# Making artifacts
mkdir -p dist
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -v -o dist/yao-${VERSION}-darwin-amd64
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -v -o dist/yao-${VERSION}-darwin-arm64
# Making artifacts - prod builds (stripped, no UPX on macOS)
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp
CGO_ENABLED=1 GOOS=darwin GOARCH=amd64 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-darwin-amd64-prod
CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -v -ldflags="-s -w" -o dist/yao-${VERSION}-darwin-arm64-prod
mkdir -p dist/release
mv dist/yao-*-* dist/release/
chmod +x dist/release/yao-*-*
ls -l dist/release/
dist/release/yao-${VERSION}-darwin-amd64 version
# Reset const
# cp -f share/const.goe share/const.go
# rm -f share/const.goe
.PHONY: debug
debug: clean
mkdir -p dist/release
# Packing
# mkdir -p .tmp/data
# cp -r ui .tmp/data/ui
# cp -r yao .tmp/data/
# go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
# rm -rf .tmp/data
mkdir -p .tmp/data
cp -r ui .tmp/data/ui
cp -r yao .tmp/data/
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
rm -rf .tmp/data
# Replace PRVERSION
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}-debug\"/g" share/const.go
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-debug\"/g" share/const.go
# Making artifacts
mkdir -p dist
@ -683,235 +210,53 @@ debug: clean
cp -f share/const.goe share/const.go
rm -f share/const.goe
# make prepare (build CUI, yao-init, bindata - shared by release and prod)
.PHONY: prepare
prepare: clean
.PHONY: release
release: clean
mkdir -p dist/release
mkdir .tmp
# Building CUI v0.9
mkdir -p .tmp/cui/v0.9/dist
echo "CUI v0.9" > .tmp/cui/v0.9/dist/index.html
# Building CUI v1.0
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
# ** new repository: https://github.com/YaoApp/cui.git **
export NODE_ENV=production
git clone https://github.com/YaoApp/cui.git .tmp/cui/v1.0
# cd .tmp/cui/v1.0 && git checkout 5002c3fded585aaa69a4366135b415ea3234964e
echo "BASE=__yao_admin_root" > .tmp/cui/v1.0/packages/cui/.env
cd .tmp/cui/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
CUI_COMMIT=$$(cd .tmp/cui/v1.0 && git rev-parse --short HEAD)
# Checkout init
git clone https://github.com/YaoApp/yao-init.git .tmp/yao-init
rm -rf .tmp/yao-init/.git
rm -rf .tmp/yao-init/.gitignore
rm -rf .tmp/yao-init/LICENSE
rm -rf .tmp/yao-init/README.md
# Switch .env login URLs from dev mode (__yao_admin_root) to release mode (dashboard)
sed -i.bak 's|AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|# AFTER_LOGIN_SUCCESS_URL="/__yao_admin_root/|g' .tmp/yao-init/.env
sed -i.bak 's|AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|# AFTER_LOGIN_FAILURE_URL="/__yao_admin_root/|g' .tmp/yao-init/.env
sed -i.bak 's|# AFTER_LOGIN_SUCCESS_URL="/dashboard/|AFTER_LOGIN_SUCCESS_URL="/dashboard/|g' .tmp/yao-init/.env
sed -i.bak 's|# AFTER_LOGIN_FAILURE_URL="/dashboard/|AFTER_LOGIN_FAILURE_URL="/dashboard/|g' .tmp/yao-init/.env
rm -f .tmp/yao-init/.env.bak
# Yao Builder
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
# mkdir -p .tmp/data/builder
# curl -o .tmp/yao-builder-latest.tar.gz https://release-sv.yaoapps.com/archives/yao-builder-latest.tar.gz
# tar -zxvf .tmp/yao-builder-latest.tar.gz -C .tmp/data/builder
# rm -rf .tmp/yao-builder-latest.tar.gz
# Building UI
git clone https://github.com/YaoApp/xgen.git .tmp/ui
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" .tmp/ui/public/icon/md_icon.css
cd .tmp/ui && cnpm install && npm run build
# Packing
cp -f data/bindata.go data/bindata.go.bak
mkdir -p .tmp/data/cui
cp -r ./ui .tmp/data/ui
cp -r ./yao .tmp/data/yao
cp -r ./sui/libsui .tmp/data/libsui
cp -r .tmp/cui/v0.9/dist .tmp/data/cui/v0.9
cp -r .tmp/cui/v1.0/packages/cui/dist .tmp/data/cui/v1.0
cp -r .tmp/yao-init .tmp/data/init
mkdir -p .tmp/data
cp -r .tmp/ui/dist .tmp/data/ui
cp -r yao .tmp/data/
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
rm -rf .tmp/data
rm -rf .tmp/ui
# Replace PRVERSION
cp -f share/const.go share/const.go.bak
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}-${NOW}\"/g" share/const.go
@CUI_COMMIT=$$(cd .tmp/cui/v1.0 && git log | head -n 1 | awk '{print substr($$2, 0, 12)}') && \
sed -ie "s/const PRCUI = \"DEV\"/const PRCUI = \"$$CUI_COMMIT-${NOW}\"/g" share/const.go
sed -ie "s/const PRVERSION = \"DEV\"/const PRVERSION = \"${COMMIT}\"/g" share/const.go
# make release (development build only, ~158M)
.PHONY: release
release: prepare
# Making artifacts - dev build
# Making artifacts
mkdir -p dist
CGO_ENABLED=1 go build -v -o dist/release/yao
chmod +x dist/release/yao
# Clean up and restore bindata.go and const.go
cp data/bindata.go.bak data/bindata.go
cp share/const.go.bak share/const.go
rm data/bindata.go.bak
rm share/const.go.bak
rm -rf .tmp
# MacOS Application Signing
@if [ "$(OS)" = "Darwin" ]; then \
codesign --deep --force --verbose --timestamp --options runtime \
--entitlements .github/codesign/entitlements.plist \
--sign "${APPLE_SIGN}" dist/release/yao ; \
fi
# make prod (production build only, ~111M on macOS)
.PHONY: prod
prod: prepare
# Set BUILDOPTIONS
@if [ "$$(uname)" = "Linux" ]; then \
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w +upx (production, compressed)"/g' share/const.go && rm -f share/const.go.tmp; \
else \
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp; \
fi
# Making artifacts - prod build
mkdir -p dist
CGO_ENABLED=1 go build -v -ldflags="-s -w" -o dist/release/yao-prod
chmod +x dist/release/yao-prod
# UPX compression (Linux only)
@if [ "$$(uname)" = "Linux" ]; then \
echo "Compressing with UPX..."; \
if command -v upx > /dev/null 2>&1; then \
upx --best dist/release/yao-prod; \
else \
echo "WARNING: UPX not found. Install with: apt install upx"; \
echo "Skipping compression."; \
fi; \
else \
echo "Note: UPX compression skipped on macOS (not supported)"; \
fi
# Clean up and restore bindata.go and const.go
cp data/bindata.go.bak data/bindata.go
cp share/const.go.bak share/const.go
rm data/bindata.go.bak
rm share/const.go.bak
rm -rf .tmp
# MacOS Application Signing
@if [ "$(OS)" = "Darwin" ]; then \
codesign --deep --force --verbose --timestamp --options runtime \
--entitlements .github/codesign/entitlements.plist \
--sign "${APPLE_SIGN}" dist/release/yao-prod ; \
fi
@echo ""
@echo "Done! Production binary:"
@ls -lh dist/release/yao-prod
@echo ""
@echo "Test with: dist/release/yao-prod version --all"
# make release-all (build both dev and prod in one go)
.PHONY: release-all
release-all: prepare
# Making artifacts - dev build (~158M)
@echo "Building dev binary..."
mkdir -p dist
CGO_ENABLED=1 go build -v -o dist/release/yao
chmod +x dist/release/yao
# Making artifacts - prod build (~111M on macOS)
@echo "Building prod binary..."
@if [ "$$(uname)" = "Linux" ]; then \
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w +upx (production, compressed)"/g' share/const.go && rm -f share/const.go.tmp; \
else \
sed -i.tmp 's/const BUILDOPTIONS = ""/const BUILDOPTIONS = "-s -w (production, stripped)"/g' share/const.go && rm -f share/const.go.tmp; \
fi
CGO_ENABLED=1 go build -v -ldflags="-s -w" -o dist/release/yao-prod
chmod +x dist/release/yao-prod
# UPX compression (Linux only)
@if [ "$$(uname)" = "Linux" ]; then \
echo "Compressing with UPX..."; \
if command -v upx > /dev/null 2>&1; then \
upx --best dist/release/yao-prod; \
else \
echo "WARNING: UPX not found. Install with: apt install upx"; \
echo "Skipping compression."; \
fi; \
else \
echo "Note: UPX compression skipped on macOS (not supported)"; \
fi
# Clean up and restore bindata.go and const.go
cp data/bindata.go.bak data/bindata.go
cp share/const.go.bak share/const.go
rm data/bindata.go.bak
rm share/const.go.bak
rm -rf .tmp
# MacOS Application Signing
@if [ "$(OS)" = "Darwin" ]; then \
codesign --deep --force --verbose --timestamp --options runtime \
--entitlements .github/codesign/entitlements.plist \
--sign "${APPLE_SIGN}" dist/release/yao ; \
codesign --deep --force --verbose --timestamp --options runtime \
--entitlements .github/codesign/entitlements.plist \
--sign "${APPLE_SIGN}" dist/release/yao-prod ; \
fi
@echo ""
@echo "Done! Binaries:"
@ls -lh dist/release/yao dist/release/yao-prod
@echo ""
@echo "Test with:"
@echo " dist/release/yao version --all"
@echo " dist/release/yao-prod version --all"
# Reset const
cp -f share/const.goe share/const.go
rm share/const.goe
.PHONY: linux-release
linux-release: clean
mkdir -p dist/release
mkdir .tmp
# Building CUI v1.0
# ** CUI will be renamed to CUI in the feature. and move to the new repository. **
# ** new repository: https://github.com/YaoApp/cui.git **
export NODE_ENV=production
git clone https://github.com/YaoApp/cui.git .tmp/cui/v1.0
rm -f .tmp/cui/v1.0/pnpm-lock.yaml
echo "BASE=__yao_admin_root" > .tmp/cui/v1.0/packages/cui/.env
cd .tmp/cui/v1.0 && pnpm install --no-frozen-lockfile && pnpm run build
# Setup UI
cd .tmp/cui/v1.0/packages/setup && pnpm install --no-frozen-lockfile && pnpm run build
# Checkout init
git clone https://github.com/YaoApp/yao-init.git .tmp/yao-init
rm -rf .tmp/yao-init/.git
rm -rf .tmp/yao-init/.gitignore
rm -rf .tmp/yao-init/LICENSE
rm -rf .tmp/yao-init/README.md
# Yao Builder
# Remove Yao Builder - DUI PageBuilder component will provide online design for pure HTML pages or SUI pages in the future.
# mkdir -p .tmp/data/builder
# curl -o .tmp/yao-builder-latest.tar.gz https://release-sv.yaoapps.com/archives/yao-builder-latest.tar.gz
# tar -zxvf .tmp/yao-builder-latest.tar.gz -C .tmp/data/builder
# rm -rf .tmp/yao-builder-latest.tar.gz
# Building UI
git clone https://github.com/YaoApp/xgen.git .tmp/ui
sed -ie "s/url('\/icon/url('\/xiang\/icon/g" .tmp/ui/public/icon/md_icon.css
cd .tmp/ui && yarn install && yarn build
# Packing
mkdir -p .tmp/data/cui
cp -r ./ui .tmp/data/ui
cp -r ./yao .tmp/data/yao
cp -r .tmp/cui/v0.9/dist .tmp/data/cui/v0.9
cp -r .tmp/cui/v1.0/packages/setup/build .tmp/data/cui/setup
cp -r .tmp/cui/v1.0/packages/cui/dist .tmp/data/cui/v1.0
cp -r .tmp/yao-init .tmp/data/init
mkdir -p .tmp/data
cp -r .tmp/ui/dist .tmp/data/ui
cp -r yao .tmp/data/
go-bindata -fs -pkg data -o data/bindata.go -prefix ".tmp/data/" .tmp/data/...
rm -rf .tmp/data
rm -rf .tmp/cui
rm -rf .tmp/ui
# Making artifacts
mkdir -p dist
@ -923,4 +268,9 @@ linux-release: clean
clean:
rm -rf ./tmp
rm -rf .tmp
rm -rf dist
rm -rf dist
# make migrate ( for unit test)
.PHONY: migrate
migrate:
$(GO) test -tags $(TESTTAGS) -run TestCommandMigrate$

143
README.md
View file

@ -1,71 +1,124 @@
# Yao — App Runtime for the AI Era
<p align="center">
<h1 align="center">YAO</h1>
</p>
Yao is an open-source runtime for building AI agents and web applications — shipped as a single binary.
<p align="center">
<a aria-label="website" href="https://yaoapps.com" target="_blank">
Website
</a>
·
<a aria-label="producthunt" href="https://www.producthunt.com/posts/yao-app-engine" target="_blank">
Producthunt
</a>
·
<a aria-label="twitter" href="https://twitter.com/YaoApp" target="_blank">
Twitter
</a>
·
<a aria-label="slack" href="https://join.slack.com/t/yaoapps/shared_invite/zt-13dm0cwvo-R9Q8xFGbrLZUffeygm9tXQ" target="_blank">
Slack
</a>
</p>
![Mission Control](docs/mission-control.png)
<p align="center">
<a aria-label="UnitTest" href="https://github.com/YaoApp/yao/actions/workflows/unit-test.yml" target="_blank">
<img src="https://github.com/YaoApp/yao/actions/workflows/unit-test.yml/badge.svg">
</a>
<a aria-label="codecov" href="https://codecov.io/gh/YaoApp/yao" target="_blank">
<img src="https://codecov.io/gh/YaoApp/yao/branch/main/graph/badge.svg?token=294Y05U71J">
</a>
<a aria-label="Go Report Card" href="https://goreportcard.com/report/github.com/yaoapp/yao" target="_blank">
<img src="https://goreportcard.com/badge/github.com/yaoapp/yao">
</a>
<a aria-label="Go Reference" href="https://pkg.go.dev/github.com/yaoapp/yao" target="_blank">
<img src="https://pkg.go.dev/badge/github.com/yaoapp/yao.svg">
</a>
</p>
**🏠 Homepage:** [https://yaoagents.com](https://yaoagents.com)
![intro](docs/1.intro.png)
**📚 Docs:** [https://yaoagents.com/docs](https://yaoagents.com/docs)
[中文介绍](README.zh-CN.md)
**🖥️ Yao Desktop:** [https://yaoagents.com/download](https://yaoagents.com/download)
## Demo
---
### Customer Relationship Management System
## How It Works
A Customer Relationship Management System developed by Yao.
Think of Yao Agent as a **cage, not an animal**. What you put inside determines the behavior; the cage keeps it controlled.
[https://demo-crm.yaoapps.com](https://demo-crm.yaoapps.com/xiang/login/admin?autoLogin=true)
Every request flows through the same pipeline:
### Intelligent warehouse management system
![Pipeline](docs/pipeline.png)
An example of cloud + edge IoT application, an unattended intelligent warehouse management system that supports face recognition and RFID.
`Create Hook` runs before the executor — inject context, enforce constraints, route requests.
`Next Hook` runs after — validate output, trigger downstream actions, drive multi-step loops.
**The AI does the heavy lifting. You define the boundaries.**
[https://demo-wms.yaoapps.com](https://demo-wms.yaoapps.com/xiang/login/admin?autoLogin=true)
### Three Modes
## Introduce
| Mode | Executor | When to use |
|------|----------|-------------|
| **LLM** | OpenAI, Anthropic, etc. | Conversational assistants, Q&A, content generation |
| **CLI Agent** | OpenCode, Claude Code, Codex in a container | Computer use, sandbox isolation, SKILL ecosystem |
| **Pure Hook** | Your own TypeScript code | Deterministic logic, routing, menu flows — no AI needed |
**Yao allows developers to create web services by processes.** Yao is a low-code engine that creates a database model, writes API services, and describes dashboard interface just by JSON for web & hardware, no code, and 10x productivity.
All three share the same Hook interface. You can mix them freely — route some requests through the LLM, handle others with pure code, all inside a single `Create Hook`.
Yao is based on the **flow-based** programming idea, developed in the **Go** language, and supports multiple ways to expand the data stream processor. This makes Yao extremely versatile, which can replace programming languages in most scenarios, and is 10 times more efficient than traditional programming languages in terms of reusability and coding efficiency; application performance and resource ratio Better than **PHP**, **JAVA** and other languages.
---
Yao has a built-in data management system. By writing **JSON** to describe the interface layout, 90% of the common interface interaction functions can be realized. It is especially suitable for quickly making various management background, CRM, ERP and other internal enterprise systems. Special interactive functions can also be implemented by writing extension components or HTML pages. The built-in management system is not coupled with Yao, and any front-end technologies such as **VUE** and **React** can be used to implement the management interface.
## Features
## Install
### Agent Framework
Run the script under terminal: (MacOS/Linux)
- **TypeScript Hooks**`Create` and `Next` hooks intercept every request; built-in V8 engine
- **Native MCP Support** — Connect tools via process, SSE, or STDIO transport
- **Memory API** — Four scopes: request-level, session, user, team
- **Multi-Agent** — Delegate to specialist agents or call agents in parallel
- **CLI Agent / Sandbox** — Run Claude Code (or other CLI runners) in an isolated container with VNC desktop support
- **Skills Ecosystem** — Drop reusable capability packs (`SKILL.md`) into any CLI Agent
```bash
curl -fsSL https://website.yaoapps.com/install.sh | bash
```
### Full-Stack Runtime
For Windows users, please refer to the Installation and Debugging chapter: [Installation and debugging](https://yaoapps.com/en-US/doc/a.Introduction/b.Install)
Everything in a single executable:
## Getting Started
- **Data Models** — Define database tables and relations in JSON/YAML
- **REST APIs** — Map routes to model queries or TypeScript processors
- **SUI Pages** — Component-based web UI with server-side rendering
- **Chat UI (CUI)** — Built-in conversation interface for agents
- **TypeScript** — Built-in V8 engine; no Node.js required
- **Single Binary** — Runs on ARM64/x64; no Python, Node, or containers needed on the host
### Step 1: Create a project
### Built-in Search
Create a new project directory, enter the project directory, and run the `yao init` command to create a blank Yao application.
- **Vector Search** — Embeddings with OpenAI or FastEmbed
- **Knowledge Graph** — Entity-relationship retrieval
- **GraphRAG** — Hybrid vector + graph search
```bash
mkdir -p /data/crm # create project directory
cd /data/crm # Enter the project directory
yao init # run the initializer
```
---
After the command runs successfully, the `app.json file` , `db`, `ui` , `data` and other directories will be created
## About the Name
```bash
├── data # Used to store files generated by the application, such as pictures, PDFs, etc.
├── db # Used to store SQLite database files
│ └── yao.db
└── ui # The static file server file directory, where custom front-end products can be placed. The files in this directory can be accessed through http://host:port/filename .
└── app.json # Application configuration file, used to define the application name, etc.
```
Yao (爻, yáo) is the fundamental symbol in the I Ching — the building block of the eight trigrams. Like a binary digit, it has two states. Their combinations describe the patterns of everything.
### Step 2: Create the data table
Use the `yao migrate` command to create the data table, open the command line terminal, **run in the project root directory**:
```bash
yao migrate
```
initialization menu
```bash
yao run flows.setmenu
```
### Step 3: Start the service
Open a command line terminal, **run in the project root directory**:
```bash
yao start
```
1. Open a browser, visit `https://127.0.0.1:5099/xiang/login/admin`,
2. Enter the default username: `xiang@iqka.com`, password: `A123456p+`
## About Yao
Yao's name is derived from the Chinese character **yao (yáo)**, the basic symbol that makes up the Eight Trigrams. The Eight Trigrams is a symbol system created by the ancient god Fuxi after observing and summarizing the laws of nature, which can refer to everything. Yao has two states of yin and yang, like 0 and 1. The transformation of yin and yang of Yao drives the replacement of Eight Trigrams, so as to summarize and record the development law of things.

View file

@ -1,73 +1,98 @@
# Yao — AI 时代的应用运行时
# Yao
Yao 是一个开源的 AI Agent 和 Web 应用运行时,以单一二进制的形式发布,下载即用。
[![UnitTest](https://github.com/YaoApp/yao/actions/workflows/unit-test.yml/badge.svg)](https://github.com/YaoApp/yao/actions/workflows/unit-test.yml)
[![codecov](https://codecov.io/gh/YaoApp/yao/branch/main/graph/badge.svg?token=294Y05U71J)](https://codecov.io/gh/YaoApp/yao)
![Mission Control](docs/mission-control.png)
![intro](docs/1.intro.png)
**🏠 官网:** [https://yaoagents.com](https://yaoagents.com)
**📚 文档:** [https://yaoagents.com/docs](https://yaoagents.com/docs)
**🖥️ Yao Desktop** [https://yaoagents.com/download](https://yaoagents.com/download)
Yao 是一款支持快速创建 Web 服务和管理后台的开源低代码应用引擎。
[English](README.md)
---
官网: [https://yaoapps.com](https://yaoapps.com)
## 工作原理
文档: [https://yaoapps.com/doc](https://yaoapps.com/doc)
Yao Agent 本质上是一个**笼子,而不是动物**。放进去的东西决定行为,笼子保证可控。
## 演示
每个请求都经过同一套管道:
### 客户管理系统
![Pipeline](docs/pipeline.png)
使用 Yao 搭建的一套通用 CRM 管理系统。
`Create Hook` 在执行器前运行 —— 注入上下文、施加约束、路由请求。
`Next Hook` 在执行器后运行 —— 校验输出、触发下游动作、驱动多步循环。
**AI 负责干活,你来划定边界。**
[https://demo-crm.yaoapps.com](https://demo-crm.yaoapps.com/xiang/login/admin?autoLogin=true)
### 三种模式
### 智能仓库管理系统
| 模式 | 执行器 | 适用场景 |
|------|--------|---------|
| **LLM** | OpenAI、Anthropic 等 | 对话助手、问答、内容生成 |
| **CLI Agent** | 容器中的 OpenCode、Claude Code、Codex | Computer Use、沙箱隔离、SKILL 生态 |
| **纯 Hook** | 你自己的 TypeScript 代码 | 确定性逻辑、菜单路由、无需 AI 的业务流程 |
使用 Yao 搭建的云+边物联网应用支持人脸识别、RFID 的无人值守智能仓库管理系统。
三种模式共享同一套 Hook 接口,可以自由混合 —— 在一个 `Create Hook` 里,部分请求走 LLM部分用纯代码处理。
[https://demo-wms.yaoapps.com](https://demo-crm.yaoapps.com/xiang/login/admin?autoLogin=true)
---
## 介绍
## 功能特性
Yao 是一个只需使用 JSON 即可创建数据库模型、编写 API 接口、描述管理后台界面的低代码引擎,使用 Yao 构建的应用可运行在云端或物联网设备上。 开发者不需要写一行代码,就可以拥有 10 倍生产力。
### Agent 框架
Yao 基于 **flow-based** 编程思想,采用 **Go** 语言开发,支持多种方式扩展数据流处理器。这使得 Yao 具有极好的**通用性**,大部分场景下可以代替编程语言, 在复用性和编码效率上是传统编程语言的 **10 倍**;应用性能和资源占比上优于 **PHP**, **JAVA** 等语言。
- **TypeScript Hook**`Create``Next` 两个钩子拦截每一次请求;内置 V8 引擎
- **原生 MCP 支持** — 通过 process、SSE 或 STDIO 传输协议接入工具
- **Memory API** — 四个作用域:请求级、会话级、用户级、团队级
- **多 Agent 协作** — 委派给专属 Agent 或并行调用多个 Agent
- **CLI Agent / 沙箱** — 在隔离容器中运行 Claude Code 等 CLI 程序,支持 VNC 桌面
- **Skills 生态** — 将可复用的能力包(`SKILL.md`)挂载到任意 CLI Agent
Yao 内置了一套数据管理系统,通过编写 **JSON** 描述界面布局,即可实现 90% 常见界面交互功能特别适合快速制作各类管理后台、CRM、ERP 等企业内部系统。对于特殊交互功能亦可通过编写扩展组件或 HTML 页面的方式实现。内置管理系统与 Yao 并不耦合,亦可采用 **VUE**, **React** 等任意前端技术实现管理界面。
### 全栈运行时
## Install
一个二进制文件包含所有能力:
在终端下运行脚本: ( MacOS / Linux )
- **数据模型** — 用 JSON/YAML 定义数据库表和关联关系
- **REST API** — 将路由映射到模型查询或 TypeScript 处理器
- **SUI 页面** — 组件化 Web UI支持服务端渲染
- **Chat UICUI** — 内置对话界面,开箱即用
- **TypeScript** — 内置 V8 引擎,不依赖 Node.js
- **单一二进制** — 支持 ARM64/x64宿主机无需 Python、Node 或容器
```bash
curl -fsSL https://website.yaoapps.com/install.sh | bash
```
### 内置搜索
Windows 用户请参考安装调试章节: [安装调试](https://yaoapps.com/doc/a.介绍/b.安装调试)
- **向量搜索** — 支持 OpenAI 或 FastEmbed 嵌入模型
- **知识图谱** — 实体关系检索
- **GraphRAG** — 向量 + 图谱混合搜索
## 入门指南
---
### Step 1: 创建项目
## 关于名字
新建一个项目目录,进入项目目录,运行 `yao init` 命令,创建一个空白的 Yao 应用。
Yao 的名字源于汉字**爻yáo**,是构成八卦的基本符号。八卦,是上古大神伏羲观测自然规律后创造的符号体系。爻有阴阳两种状态,就像 0 和 1。爻的阴阳转换驱动八卦更替记录事物的发展规律。
```bash
mkdir -p /data/crm # 创建项目目录
cd /data/crm # 进入项目目录
yao init # 运行初始化程序
```
命令运行成功后,将创建 `app.json文件` , `db`, `ui` , `data` 等目录
```bash
├── data # 用于存放应用产生的文件,如图片,PDF等
├── db # 用于存放 SQLite 数据库文件
│ └── yao.db
└── ui # 静态文件服务器文件目录,可以放置自定义前端制品,该目录下文件可通过 http://host:port/文件名称 访问。
└── app.json # 应用配置文件, 用来定义应用名称等
```
### Step 2: Create the data table
使用 `yao migrate` 命令创建数据表,打开命令行终端,**在项目根录下运行**:
```bash
yao migrate
```
初始化菜单
```bash
yao run flows.setmenu
```
### Step 3: Start the service
打开命令行终端,**在项目根录下运行**:
```bash
yao start
```
1. 打开浏览器, 访问 `https://127.0.0.1:5099/xiang/login/admin`
2. 输入默认用户名: `xiang@iqka.com` 密码: `A123456p+`
## 关于 Yao
Yao 的名字源于汉字**爻(yáo)**,是构成八卦的基本符号。八卦,是上古大神伏羲观测总结自然规律后,创造的一个可以指代万事万物的符号体系。爻,有阴阳两种状态,就像 0 和 1。爻的阴阳转换驱动八卦更替以此来总结记录事物的发展规律。

View file

@ -1,259 +0,0 @@
# Yao Agent
A powerful AI assistant framework for building intelligent conversational agents with tool integration, knowledge base search, and multi-agent orchestration.
## Quick Start
### 1. Create an Assistant
```
assistants/
└── my-assistant/
├── package.yao # Configuration
├── prompts.yml # System prompts
└── locales/
└── en-us.yml # Translations
```
**package.yao**
```json
{
"name": "{{ name }}",
"connector": "gpt-4o",
"description": "{{ description }}",
"placeholder": {
"title": "{{ chat.title }}",
"prompts": ["{{ chat.prompts.0 }}"]
}
}
```
**prompts.yml**
```yaml
- role: system
content: |
You are a helpful assistant.
```
**locales/en-us.yml**
```yaml
name: My Assistant
description: A helpful AI assistant
chat:
title: New Chat
prompts:
- How can I help you today?
```
### 2. Add Hooks (Optional)
Create `src/index.ts` for custom logic:
```typescript
import { agent } from "@yao/runtime";
function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
// Preprocess messages before LLM call
return { messages };
}
function Next(ctx: agent.Context, payload: agent.Payload): agent.Next {
// Post-process LLM response
return null;
}
```
### 3. Test (Optional)
```bash
# Run tests
yao agent test -i "Hello, how are you?"
# Run tests from JSONL file
yao agent test -i tests/inputs.jsonl -v
# Extract results for review
yao agent extract output-*.jsonl
```
### 4. Run
```bash
yao start
```
Access via API: `POST /v1/chat/completions`
## Examples
### Hook: Route to Specialist
```typescript
// src/index.ts
function Create(ctx: agent.Context, messages: agent.Message[]): agent.Create {
const last = messages[messages.length - 1]?.content || "";
if (last.includes("refund")) {
return { delegate: { agent_id: "refund-specialist", messages } };
}
return null;
}
```
### Database Query
```json
// package.yao - Enable auto DB search
{ "db": { "models": ["orders", "products"] } }
```
```bash
# Test: Agent auto-generates QueryDSL and searches database
yao agent test -i "Find orders over $1000 from last month"
```
### MCP Tools (Process Transport)
```json
// mcps/tools.mcp.yao - Define MCP server with Yao Processes
{
"label": "Tools",
"transport": "process",
"tools": {
"search_orders": "models.order.Paginate",
"create_order": "models.order.Create"
}
}
```
```json
// mcps/mapping/tools/schemes/search_orders.in.yao - Input schema
{
"type": "object",
"properties": {
"keyword": { "type": "string" },
"page": { "type": "integer" }
},
"x-process-args": [":arguments"]
}
```
```json
// package.yao
{ "mcp": { "servers": [{ "server_id": "tools" }] } }
```
### Sidebar Page (Display Data)
Pages render in the right sidebar during conversation to display structured data:
```html
<!-- pages/result/result.html - Display query results -->
<div class="result-panel">
<h3>{{ title }}</h3>
<table s:if="{{ rows.length > 0 }}">
<tr s:for="{{ rows }}" s:for-item="row">
<td>{{ row.name }}</td>
<td>{{ row.value }}</td>
</tr>
</table>
</div>
```
```bash
yao sui build agent # Build pages
```
```javascript
// In hook: send action to open page in sidebar
ctx.Send({
type: "action",
props: {
name: "navigate",
payload: {
route: "/agents/my-assistant/result",
title: "Query Results",
query: { id: "123" }, // Passed as $query in page
},
},
});
```
## Documentation
- [Configuration](docs/configuration.md) - Assistant settings, connectors, options
- [Prompts](docs/prompts.md) - System prompts and prompt presets
- [Hooks](docs/hooks.md) - Create/Next hooks and agent lifecycle
- [Context API](docs/context-api.md) - Messaging, memory, trace, MCP
- [MCP Integration](docs/mcp.md) - Tool servers and resources
- [Models](docs/models.md) - Assistant-scoped data models
- [Search](docs/search.md) - Web, knowledge base, and database search
- [Pages](docs/pages.md) - Web UI for agents (SUI framework)
- [Iframe Integration](docs/iframe.md) - Iframe communication with CUI
- [Internationalization](docs/i18n.md) - Multi-language support
- [Testing](docs/testing.md) - Agent testing framework
## Architecture
```mermaid
flowchart LR
subgraph Request
A[User Request]
end
subgraph Create["Create Hook"]
B1[Preprocess Messages]
B2[Configure LLM]
B3[Delegate to Agent]
end
subgraph LLM["LLM Call"]
C1[Load Prompts]
C2[Generate Response]
end
subgraph Tools["Tool Execution"]
D1[MCP Tools]
D2[Search]
D3[Memory]
end
subgraph Next["Next Hook"]
E1[Process Results]
E2[Transform Output]
E3[Delegate to Agent]
end
subgraph Response
F[Stream Response]
end
A --> Create
Create --> LLM
LLM --> Tools
Tools --> Next
Next --> Response
Next -.->|Continue| LLM
```
## API Endpoints
OpenAPI endpoints (base URL: `/v1`):
| Endpoint | Method | Description |
| -------------------------------------- | ------ | --------------------- |
| `/v1/chat/completions` | POST | Chat with assistant |
| `/v1/chat/sessions` | GET | List chat sessions |
| `/v1/chat/sessions/:chat_id` | GET | Get chat session |
| `/v1/chat/sessions/:chat_id/messages` | GET | Get messages |
| `/v1/agent/assistants` | GET | List assistants |
| `/v1/agent/assistants/:id` | GET | Get assistant details |
| `/v1/file/:uploaderID` | POST | Upload files |
| `/v1/file/:uploaderID/:fileID` | GET | Get file info |
| `/v1/file/:uploaderID/:fileID/content` | GET | Download file |
## License
This project is part of the Yao App Engine and follows the [Yao Open Source License](../LICENSE).

View file

@ -1,327 +0,0 @@
package agent
// type customResponseRecorder struct {
// *httptest.ResponseRecorder
// closeChannel chan bool
// }
// func (r *customResponseRecorder) CloseNotify() <-chan bool {
// return r.closeChannel
// }
// func newCustomResponseRecorder() *customResponseRecorder {
// return &customResponseRecorder{
// ResponseRecorder: httptest.NewRecorder(),
// closeChannel: make(chan bool, 1),
// }
// }
// func TestDSL_Prompts(t *testing.T) {
// test.Prepare(t, config.Conf)
// defer Test_clean(t)
// resetDB()
// agent := &DSL{
// Prompts: []Prompt{
// {Role: "system", Content: "You are a helpful assistant", Name: "ai"},
// {Role: "user", Content: "Hello", Name: "user"},
// },
// ConversationSetting: conversation.Setting{
// Connector: "default",
// Table: "chat_messages",
// },
// }
// err := agent.newConversation()
// assert.NoError(t, err)
// prompts := agent.prompts()
// assert.Equal(t, 2, len(prompts))
// assert.Equal(t, "system", prompts[0]["role"])
// assert.Equal(t, "You are a helpful assistant", prompts[0]["content"])
// assert.Equal(t, "ai", prompts[0]["name"])
// }
// func TestDSL_ChatMessages(t *testing.T) {
// test.Prepare(t, config.Conf)
// defer Test_clean(t)
// resetDB()
// agent := &DSL{
// Prompts: []Prompt{
// {Role: "system", Content: "You are a helpful assistant"},
// },
// ConversationSetting: conversation.Setting{
// Connector: "default",
// Table: "chat_messages",
// },
// }
// err := agent.newConversation()
// assert.NoError(t, err)
// ctx := Context{
// Sid: "test-session",
// ChatID: "test-chat",
// }
// messages, err := agent.chatMessages(ctx, "Hello AI")
// assert.NoError(t, err)
// assert.Equal(t, 2, len(messages))
// assert.Equal(t, "system", messages[0]["role"])
// assert.Equal(t, "user", messages[1]["role"])
// assert.Equal(t, "Hello AI", messages[1]["content"])
// }
// func TestDSL_Answer(t *testing.T) {
// test.Prepare(t, config.Conf)
// defer Test_clean(t)
// gin.SetMode(gin.TestMode)
// w := newCustomResponseRecorder()
// c, _ := gin.CreateTestContext(w)
// ctx := Context{
// Sid: "test-session",
// ChatID: "test-chat",
// Context: context.Background(),
// }
// resetDB()
// agent := &DSL{
// Connector: "gpt-3_5-turbo",
// Option: map[string]interface{}{
// "temperature": 0.7,
// "max_tokens": 150,
// },
// Prompts: []Prompt{
// {Role: "system", Content: "You are a helpful assistant"},
// },
// ConversationSetting: conversation.Setting{
// Connector: "default",
// Table: "chat_messages",
// },
// }
// err := agent.newAI()
// assert.NoError(t, err)
// err = agent.newConversation()
// assert.NoError(t, err)
// c.Request = httptest.NewRequest("POST", "/chat", nil)
// agent.AI = &mockAI{}
// err = agent.Answer(ctx, "Hello AI", c)
// assert.NoError(t, err)
// }
// // func TestDSL_NewAI(t *testing.T) {
// // test.Prepare(t, config.Conf)
// // defer Test_clean(t)
// // tests := []struct {
// // name string
// // connector string
// // wantErr string
// // }{
// // {
// // name: "Mock AI",
// // connector: "mock",
// // wantErr: "",
// // },
// // {
// // name: "Specific mock model",
// // connector: "mock:gpt-4",
// // wantErr: "",
// // },
// // {
// // name: "Invalid connector",
// // connector: "invalid-connector",
// // wantErr: "AI connector invalid-connector not found",
// // },
// // }
// // for _, tt := range tests {
// // t.Run(tt.name, func(t *testing.T) {
// // agent := &DSL{
// // Connector: tt.connector,
// // }
// // agent.newConversation()
// // assert.Panics(t, func() {
// // agent.newAI()
// // })
// // })
// // }
// // }
// func TestDSL_Select(t *testing.T) {
// test.Prepare(t, config.Conf)
// defer Test_clean(t)
// resetDB()
// agent := &DSL{
// ConversationSetting: conversation.Setting{
// Connector: "default",
// Table: "chat_messages",
// },
// }
// err := agent.newConversation()
// assert.NoError(t, err)
// err = agent.Select("invalid-model")
// assert.Error(t, err)
// // err = agent.Select("gpt-3_5-turbo")
// // assert.NoError(t, err)
// // assert.NotNil(t, agent.AI)
// }
// // func TestDSL_NewConversation(t *testing.T) {
// // test.Prepare(t, config.Conf)
// // defer Test_clean(t)
// // tests := []struct {
// // name string
// // connector string
// // wantErr bool
// // }{
// // {
// // name: "Default connector",
// // connector: "default",
// // wantErr: false,
// // },
// // {
// // name: "Empty connector",
// // connector: "",
// // wantErr: false,
// // },
// // {
// // name: "Invalid connector",
// // connector: "invalid-connector",
// // wantErr: true,
// // },
// // }
// // for _, tt := range tests {
// // t.Run(tt.name, func(t *testing.T) {
// // agent := &DSL{
// // ConversationSetting: conversation.Setting{
// // Connector: tt.connector,
// // },
// // }
// // assert.Panics(t, func() {
// // agent.newConversation()
// // })
// // })
// // }
// // }
// func TestDSL_SaveHistory(t *testing.T) {
// test.Prepare(t, config.Conf)
// defer Test_clean(t)
// agent := &DSL{
// ConversationSetting: conversation.Setting{
// Connector: "default",
// Table: "chat_messages",
// },
// }
// resetDB()
// err := agent.newConversation()
// assert.NoError(t, err)
// messages := []map[string]interface{}{
// {
// "role": "user",
// "content": "Hello",
// "name": "test-user",
// },
// }
// content := []byte("Hi there!")
// agent.saveHistory("test-session", "test-chat", content, messages)
// // Verify the history was saved
// history, err := agent.Conversation.GetHistory("test-session", "test-chat")
// assert.NoError(t, err)
// assert.NotEmpty(t, history)
// }
// func TestDSL_Send(t *testing.T) {
// test.Prepare(t, config.Conf)
// defer Test_clean(t)
// gin.SetMode(gin.TestMode)
// w := httptest.NewRecorder()
// c, _ := gin.CreateTestContext(w)
// resetDB()
// agent := &DSL{
// ConversationSetting: conversation.Setting{
// Connector: "default",
// Table: "chat_messages",
// },
// }
// err := agent.newConversation()
// assert.NoError(t, err)
// ctx := Context{
// Sid: "test-session",
// ChatID: "test-chat",
// }
// msg := &message.JSON{
// Message: &message.Message{Text: "Test message"},
// }
// messages := []map[string]interface{}{
// {"role": "user", "content": "Hello"},
// }
// content := []byte("Test content")
// err = agent.send(ctx, msg, messages, content, c)
// assert.NoError(t, err)
// }
// func Test_clean(t *testing.T) {
// defer test.Clean()
// }
// func resetDB() {
// sch := capsule.Global.Schema()
// sch.DropTable("chat_messages")
// }
// type mockAI struct{}
// func (m *mockAI) ChatCompletionsWith(ctx context.Context, messages []map[string]interface{}, options map[string]interface{}, callback func([]byte) int) (interface{}, *exception.Exception) {
// callback([]byte(`{"choices":[{"delta":{"content":"Mock response"}}]}`))
// callback([]byte(`{"choices":[{"finish_reason":"stop"}]}`))
// return nil, nil
// }
// func (m *mockAI) ChatCompletions(messages []map[string]interface{}, options map[string]interface{}, callback func([]byte) int) (interface{}, *exception.Exception) {
// return nil, nil
// }
// func (m *mockAI) GetContent(response interface{}) (string, *exception.Exception) {
// return "Mock content", nil
// }
// func (m *mockAI) Embeddings(input interface{}, user string) (interface{}, *exception.Exception) {
// return nil, nil
// }
// func (m *mockAI) Tiktoken(input string) (int, error) {
// return 0, nil
// }
// func (m *mockAI) MaxToken() int {
// return 4096
// }

View file

@ -1,864 +0,0 @@
package assistant
import (
"fmt"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/assistant/handlers"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/llmprovider"
)
// Stream stream the agent
// handler is optional, if not provided, a default handler will be used
func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Message, options ...*context.Options) (*context.Response, error) {
// Update logger with assistant ID and start logging
ctx.Logger.SetAssistantID(ast.ID)
ctx.Logger.Start()
// Validate user permissions
var err error
err = ast.checkPermissions(ctx)
if err != nil {
return nil, err
}
// Start stream time
streamStartTime := time.Now()
// Set up interrupt handler if interrupt controller is available
// InterruptController handles user interrupt signals (stop button) for appending messages
// HTTP context cancellation is handled naturally by LLM/Agent layers
if ctx.Interrupt != nil {
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
return ast.handleInterrupt(c, signal)
})
}
// ================================================
// Initialize
// ================================================
ctx.Logger.Phase("Initialize")
// Get or create options
var opts *context.Options
if len(options) > 0 && options[0] != nil {
opts = options[0]
} else {
opts = &context.Options{}
}
// Merge caller-provided metadata into ctx so sub-agent hooks can read it via ctx.metadata
ctx.MergeMetadata(opts.Metadata)
// Initialize stack and auto-handle completion/failure/restore
_, _, done := context.EnterStack(ctx, ast.ID, opts)
defer done()
// Auto-skip history for forked Agent-to-Agent calls (ctx.agent.Call/All/Any/Race)
// This ensures forked A2A messages don't pollute chat history.
// Delegate calls (RefererAgent) still save history as they are part of the main conversation flow.
// Note: Output is NOT skipped - sub-agents output normally with ThreadID for UI separation.
if ctx.IsForkedA2ACall() {
if opts == nil {
opts = &context.Options{}
}
opts.ForceA2A()
}
// ================================================
// Initialize Chat Buffer (for root stack only)
// Buffer is flushed in defer block at the end
// ================================================
ast.InitBuffer(ctx)
// Track final status for buffer flush
var finalStatus = context.StepStatusCompleted
var finalError error
// Defer buffer flush - always executes on exit (success, error, interrupt, panic)
defer func() {
// Handle panic recovery for status tracking
if r := recover(); r != nil {
finalStatus = context.ResumeStatusFailed
if e, ok := r.(error); ok {
finalError = e
} else {
finalError = fmt.Errorf("panic: %v", r)
}
ctx.Logger.Error("Panic recovered in Stream: %v", r)
// Re-panic after flush to preserve original behavior
defer panic(r)
}
// Flush buffer to database
ast.FlushBuffer(ctx, finalStatus, finalError)
// Log end of request
ctx.Logger.End(finalStatus == context.StepStatusCompleted, finalError)
ctx.Logger.RestoreAssistantID()
}()
// Determine stream handler
streamHandler := ast.getStreamHandler(ctx, opts)
// Get connector and capabilities early (before sending stream_start)
// so that output adapters can use them when converting stream_start event
err = ast.initializeCapabilities(ctx, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Send ChunkStreamStart only for root stack (agent-level stream start)
// Now ctx.Capabilities is set, so output adapters can use it
ast.sendAgentStreamStart(ctx, streamHandler, streamStartTime)
// Initialize chat, prepare kb collection (optional) etc.
// Use async version to not block the main flow
ast.InitializeConversationAsync(ctx, opts)
ctx.Logger.PhaseComplete("Initialize")
// Ensure chat session exists
ast.EnsureChat(ctx)
// Initialize agent trace node
agentNode := ast.initAgentTraceNode(ctx, inputMessages)
// ================================================
// Get Full Messages with chat history
// ================================================
ctx.Logger.Phase("History")
historyResult, err := ast.WithHistory(ctx, inputMessages, agentNode, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
fullMessages := historyResult.FullMessages
// Buffer user input messages (use cleaned input without overlap)
// Skip if History is disabled in options (for internal calls like needsearch)
// Note: For A2A calls, ForceA2A() sets skip.history = true, so this will be skipped
if opts == nil || opts.Skip == nil || !opts.Skip.History {
ast.BufferUserInput(ctx, historyResult.InputMessages)
}
ctx.Logger.PhaseComplete("History")
// ================================================
// Initialize Sandbox (if configured)
// ================================================
// Sandbox must be created BEFORE hooks so that hooks can access ctx.sandbox
var sandboxExecutor agentsandbox.Executor
var sandboxCleanup func()
var sandboxLoadingMsgID string
// V2 sandbox state
var v2Init *sandboxV2InitResult
if ast.HasSandboxV2() {
ctx.Logger.Phase("Sandbox V2")
var err error
v2Init, err = ast.initSandboxV2(ctx, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
sandboxCleanup = v2Init.Cleanup
ctx.Logger.PhaseComplete("Sandbox V2")
if v2Init.Computer != nil {
ci := v2Init.Computer.ComputerInfo()
ctx.Logger.Trace("Node: %s (%s)", ci.NodeID, ci.Kind)
if ci.BoxID != "" {
ctx.Logger.Trace("Computer: %s", ci.BoxID)
}
ctx.Logger.Trace("Workspace: %s", v2Init.Config.WorkspaceID)
if conn, _, err := ast.GetConnector(ctx, opts); err == nil && conn != nil {
ctx.Logger.Trace("Connector: %s", conn.ID())
}
}
} else if ast.HasSandbox() {
ctx.Logger.Phase("Sandbox")
var err error
sandboxExecutor, sandboxCleanup, sandboxLoadingMsgID, err = ast.initSandbox(ctx, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Set sandbox executor in context so hooks can access ctx.sandbox
// The executor implements both agentsandbox.Executor and context.SandboxExecutor
ctx.SetSandboxExecutor(sandboxExecutor)
ctx.Logger.PhaseComplete("Sandbox")
}
// Ensure sandbox cleanup on exit
defer func() {
if sandboxCleanup != nil {
sandboxCleanup()
}
}()
// ================================================
// Standalone Workspace Loading (no sandbox required)
// ================================================
// When no sandbox is configured but the user selected a workspace,
// load the workspace FS into context so hooks can access ctx.workspace.
if !ctx.HasWorkspace() {
ast.initStandaloneWorkspace(ctx)
}
// ================================================
// Execute Create Hook
// ================================================
// Request Create hook ( Optional )
var createResponse *context.HookCreateResponse
if ast.HookScript != nil {
ctx.Logger.HookStart("Create")
// Begin step tracking for hook_create
ast.BeginStep(ctx, context.StepTypeHookCreate, map[string]interface{}{
"messages": fullMessages,
})
var err error
createResponse, opts, err = ast.HookScript.Create(ctx, fullMessages, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
// Send error stream_end for root stack
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete step
ast.CompleteStep(ctx, map[string]interface{}{
"response": createResponse,
})
// Log the create response
ast.traceCreateHook(agentNode, createResponse)
ctx.Logger.HookComplete("Create")
// Check if Create hook wants to delegate to another agent
// This allows early routing to sub-agents without LLM call
if createResponse != nil && createResponse.Delegate != nil {
ctx.Logger.Debug("Create hook delegating to agent: %s", createResponse.Delegate.AgentID)
// Delegate to target agent (reuse existing delegation logic from next.go)
// Note: User input is already buffered by root agent, delegated agent will skip buffering
delegateResponse, err := ast.handleDelegation(ctx, createResponse.Delegate, streamHandler)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// For root stack, send stream_end and close output
// (delegated agent handles its own stream events, but root needs to close)
if ctx.Stack != nil && ctx.Stack.IsRoot() {
ast.sendAgentStreamEnd(ctx, streamHandler, streamStartTime, "completed", nil, nil)
if err := ctx.CloseOutput(); err != nil {
if trace, _ := ctx.Trace(); trace != nil {
trace.Error(i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.close_error"), map[string]any{"error": err.Error()})
}
}
}
// Return delegated response directly (skip LLM call and Next hook)
return delegateResponse, nil
}
}
// ================================================
// Execute LLM Call Stream
// ================================================
// LLM Call Stream ( Optional )
var completionResponse *context.CompletionResponse
var completionMessages []context.Message
var completionOptions *context.CompletionOptions
if ast.Prompts != nil || ast.MCP != nil {
ctx.Logger.Phase("LLM")
// Build the LLM request first (use fullMessages which includes history)
// Note: completionMessages here are still in original format (with __yao.attachment:// URLs)
// Content conversion (BuildContent) happens inside executeLLMStream, right before LLM call
// This ensures autoSearch and delegate receive original messages, not converted ones
completionMessages, completionOptions, err = ast.BuildRequest(ctx, fullMessages, createResponse)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
// Send error stream_end for root stack
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// ================================================
// Execute Auto Search (if enabled)
// ================================================
if intent := ast.shouldAutoSearch(ctx, completionMessages, createResponse, opts); intent != nil {
refCtx := ast.executeAutoSearch(ctx, completionMessages, createResponse, intent, opts)
if refCtx != nil && len(refCtx.References) > 0 {
completionMessages = ast.injectSearchContext(completionMessages, refCtx)
}
}
// Begin step tracking for LLM call
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
"messages": completionMessages,
})
// Execute the LLM streaming call
// Choose between sandbox execution or direct LLM execution
if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Computer != nil && v2Init.Runner.Name() != "yao" {
// V2 Sandbox execution path (non-yao runners replace LLM.Stream)
completionResponse, err = ast.executeSandboxV2Stream(ctx, &sandboxV2StreamParams{
Messages: completionMessages,
AgentNode: agentNode,
Handler: streamHandler,
Runner: v2Init.Runner,
Computer: v2Init.Computer,
Config: v2Init.Config,
LoadingMsgID: v2Init.LoadingMsgID,
Options: opts,
Roles: v2Init.Roles,
})
} else if ast.HasSandboxV2() && v2Init != nil && v2Init.Runner != nil && v2Init.Runner.Name() == "yao" {
// V2 yao runner: Prepare is done, close loading, fall through to LLM
if v2Init.LoadingMsgID != "" {
closeLoadingV2(ctx, v2Init.LoadingMsgID, "")
}
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
} else if ast.HasSandbox() {
// V1 Sandbox execution path (Claude CLI, Cursor CLI, etc.)
completionResponse, err = ast.executeSandboxStream(ctx, completionMessages, agentNode, streamHandler, sandboxExecutor, sandboxLoadingMsgID)
} else {
// Direct LLM execution path
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
}
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
// Send error stream_end for root stack
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete LLM step
ast.CompleteStep(ctx, map[string]interface{}{
"content": completionResponse.Content,
"tool_calls": completionResponse.ToolCalls,
})
hasToolCalls := completionResponse != nil && completionResponse.ToolCalls != nil && len(completionResponse.ToolCalls) > 0
tokens := 0
if completionResponse != nil && completionResponse.Usage != nil {
tokens = completionResponse.Usage.TotalTokens
}
ctx.Logger.LLMComplete(tokens, hasToolCalls)
ctx.Logger.PhaseComplete("LLM")
}
// ================================================
// Execute tool calls with retry
// ================================================
// Note: Skip MCP tool calls execution for sandbox mode - Claude CLI handles them internally
var toolCallResponses []context.ToolCallResponse = nil
if completionResponse != nil && completionResponse.ToolCalls != nil && !ast.HasSandbox() {
maxToolRetries := 3
currentMessages := completionMessages
currentResponse := completionResponse
for attempt := 0; attempt < maxToolRetries; attempt++ {
// Begin step tracking for tool calls
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
"tool_calls": currentResponse.ToolCalls,
"attempt": attempt,
})
// Execute all tool calls
toolResults, hasErrors := ast.executeToolCalls(ctx, currentResponse.ToolCalls, attempt)
// Build a map of tool call ID to arguments for quick lookup
toolCallArgsMap := make(map[string]interface{})
for _, tc := range currentResponse.ToolCalls {
toolCallArgsMap[tc.ID] = tc.Function.Arguments
}
// Convert toolResults to toolCallResponses
toolCallResponses = make([]context.ToolCallResponse, len(toolResults))
for i, result := range toolResults {
parsedContent, _ := result.ParsedContent()
toolCallResponses[i] = context.ToolCallResponse{
ToolCallID: result.ToolCallID,
Server: result.Server(),
Tool: result.Tool(),
Arguments: toolCallArgsMap[result.ToolCallID],
Result: parsedContent,
Error: "",
}
if result.Error != nil {
toolCallResponses[i].Error = result.Error.Error()
}
}
// If all successful, complete step and break out
if !hasErrors {
ast.CompleteStep(ctx, map[string]interface{}{
"results": toolCallResponses,
})
ctx.Logger.Debug("All tool calls succeeded (attempt %d)", attempt)
break
}
// Check if any errors are retryable (parameter/validation issues)
hasRetryableErrors := false
for _, result := range toolResults {
if result.Error != nil && result.IsRetryableError {
hasRetryableErrors = true
break
}
}
// If no retryable errors, don't retry (MCP internal issues)
if !hasRetryableErrors {
err := fmt.Errorf("tool calls failed with non-retryable errors (MCP internal issues)")
finalStatus = context.ResumeStatusFailed
finalError = err
ctx.Logger.Error("Tool calls failed: %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// If it's the last attempt, return error
if attempt == maxToolRetries-1 {
err := fmt.Errorf("tool calls failed after %d attempts", maxToolRetries)
finalStatus = context.ResumeStatusFailed
finalError = err
ctx.Logger.Error("Tool calls failed: %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete current step (with partial results)
ast.CompleteStep(ctx, map[string]interface{}{
"results": toolCallResponses,
"has_errors": true,
})
// Build retry messages with tool call results (including errors)
retryMessages := ast.buildToolRetryMessages(currentMessages, currentResponse, toolResults)
// Begin LLM retry step
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
"messages": retryMessages,
"retry_attempt": attempt + 1,
})
// Retry LLM call (streaming to keep user informed)
ctx.Logger.Debug("Retrying LLM for tool call correction (attempt %d/%d)", attempt+1, maxToolRetries-1)
currentResponse, err = ast.executeLLMForToolRetry(ctx, retryMessages, completionOptions, agentNode, streamHandler, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ctx.Logger.Error("LLM retry failed: %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// If LLM didn't return tool calls, it might have given up
if currentResponse.ToolCalls == nil {
err := fmt.Errorf("LLM did not return tool calls in retry attempt %d", attempt+1)
finalStatus = context.ResumeStatusFailed
finalError = err
ctx.Logger.Error("LLM did not return tool calls: %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete LLM retry step
ast.CompleteStep(ctx, map[string]interface{}{
"content": currentResponse.Content,
"tool_calls": currentResponse.ToolCalls,
})
// Update messages for next iteration
currentMessages = retryMessages
}
// Update completionResponse with the final successful response
completionResponse = currentResponse
}
// ================================================
// Execute Next Hook and Process Response
// ================================================
var finalResponse *context.Response
var nextResponse *context.NextHookResponse = nil
if ast.HookScript != nil {
ctx.Logger.HookStart("Next")
// Begin step tracking for hook_next
ast.BeginStep(ctx, context.StepTypeHookNext, map[string]interface{}{
"messages": fullMessages,
"completion": completionResponse,
"tools": toolCallResponses,
})
var err error
nextResponse, opts, err = ast.HookScript.Next(ctx, &context.NextHookPayload{
Messages: fullMessages,
Completion: completionResponse,
Tools: toolCallResponses,
}, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete hook_next step
ast.CompleteStep(ctx, map[string]interface{}{
"response": nextResponse,
})
ctx.Logger.HookComplete("Next")
// Process Next hook response
finalResponse, err = ast.processNextResponse(&NextProcessContext{
Context: ctx,
NextResponse: nextResponse,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
StreamHandler: streamHandler,
CreateResponse: createResponse,
})
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
} else if len(toolCallResponses) > 0 && !ast.HasSandbox() && !ast.isToolLoopDisabled() {
// No Next hook + has tool results + not sandbox → tool loop
ctx.Logger.Debug("Entering tool loop for tool result processing")
loopResponse, loopCompletion, loopTools, err := ast.executeToolLoop(ctx, &ToolLoopParams{
CompletionMessages: completionMessages,
CompletionOptions: completionOptions,
CompletionResponse: completionResponse,
ToolCallResponses: toolCallResponses,
FullMessages: fullMessages,
AgentNode: agentNode,
StreamHandler: streamHandler,
CreateResponse: createResponse,
Opts: opts,
})
if err != nil {
// Fallback to __yao.loop_fallback delegation
ctx.Logger.Warn("Tool loop failed: %v, falling back to loop_fallback", err)
fallbackDelegate := ast.buildLoopFallbackDelegate(ctx, fullMessages, completionResponse, toolCallResponses)
delegateResponse, delegateErr := ast.handleDelegation(ctx, fallbackDelegate, streamHandler)
if delegateErr != nil {
ctx.Logger.Warn("loop_fallback also failed: %v, using standard response", delegateErr)
finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
StreamHandler: streamHandler,
CreateResponse: createResponse,
})
} else {
finalResponse = delegateResponse
}
} else {
completionResponse = loopCompletion
toolCallResponses = loopTools
finalResponse = loopResponse
}
} else {
// No tool calls, sandbox mode, or loop disabled: standard response
finalResponse = ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: completionResponse,
FullMessages: fullMessages,
ToolCallResponses: toolCallResponses,
StreamHandler: streamHandler,
CreateResponse: createResponse,
})
}
// Create completion node to report final output
ast.traceAgentCompletion(ctx, createResponse, nextResponse, completionResponse, finalResponse)
// Only close output and send stream_end if this is the root call (entry point)
// Nested calls (from MCP, hooks, etc.) should not close the output or send stream_end
// Note: Flush is already handled by the stream handler (handleStreamEnd)
if ctx.Stack != nil && ctx.Stack.IsRoot() {
// Log closing output for root call
if trace, _ := ctx.Trace(); trace != nil {
trace.Debug("Agent: Closing output (root call)", map[string]any{
"stack_id": ctx.Stack.ID,
"depth": ctx.Stack.Depth,
"assistant_id": ctx.Stack.AssistantID,
})
}
// Send ChunkStreamEnd (agent-level stream completion)
ast.sendAgentStreamEnd(ctx, streamHandler, streamStartTime, "completed", nil, completionResponse)
// Close the output writer to send [DONE] marker
if err := ctx.CloseOutput(); err != nil {
if trace, _ := ctx.Trace(); trace != nil {
trace.Error(i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.close_error"), map[string]any{"error": err.Error()}) // "Failed to close output"
}
}
} else {
// Log skipping close for nested call
if trace, _ := ctx.Trace(); trace != nil && ctx.Stack != nil {
trace.Debug("Agent: Skipping output close (nested call)", map[string]any{
"stack_id": ctx.Stack.ID,
"depth": ctx.Stack.Depth,
"parent_id": ctx.Stack.ParentID,
"assistant_id": ctx.Stack.AssistantID,
})
}
}
// Return finalResponse which could be:
// 1. Result from delegated agent call (already a Response)
// 2. Custom data from Next hook (wrapped in standard Response)
// 3. Standard response
return finalResponse, nil
}
// GetConnector get the connector object, capabilities, and error.
// Priority: opts.Connector > ast.Connector (may be "use::<role>") > "default" role > legacy fallback
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) {
cid := ast.Connector
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
cid = opts[0].Connector
}
// Extract identity for role-based resolution
var identity llmprovider.Identity
if ctx != nil && ctx.Authorized != nil {
identity = ctx.Authorized
}
// Unified resolution: explicit connector / use:: prefix / empty → all handled
conn, caps, err := llm.ResolveConnector(cid, identity)
if err == nil {
return conn, caps, nil
}
// Legacy fallback
if defaultConnector != "" {
if conn, err := connector.Select(defaultConnector); err == nil {
log.Warn("[LLM] Connector %s resolve failed, fallback to %s", cid, defaultConnector)
return conn, llm.GetCapabilitiesFromConn(conn), nil
}
}
if fallback := findCapableConnector(); fallback != "" {
if conn, err := connector.Select(fallback); err == nil {
log.Warn("[LLM] Connector %s resolve failed, fallback to %s (auto-detected)", cid, fallback)
return conn, llm.GetCapabilitiesFromConn(conn), nil
}
}
return nil, nil, fmt.Errorf("connector not specified")
}
// Info get the assistant information
func (ast *Assistant) Info(locale ...string) *message.AssistantInfo {
lc := "en"
if len(locale) > 0 {
lc = locale[0]
}
return &message.AssistantInfo{
ID: ast.ID,
Type: ast.Type,
Name: i18n.Tr(ast.ID, lc, ast.Name),
Avatar: ast.Avatar,
Description: i18n.Tr(ast.ID, lc, ast.Description),
}
}
// getStreamHandler returns the stream handler from options or a default one
func (ast *Assistant) getStreamHandler(ctx *context.Context, opts ...*context.Options) message.StreamFunc {
// Check if handler is provided in options
if len(opts) > 0 && opts[0] != nil && opts[0].Writer != nil {
return handlers.DefaultStreamHandler(ctx)
}
return handlers.DefaultStreamHandler(ctx)
}
// sendAgentStreamStart sends ChunkStreamStart for root stack only (agent-level stream start)
// This ensures only one stream_start per agent execution, even with multiple LLM calls
func (ast *Assistant) sendAgentStreamStart(ctx *context.Context, handler message.StreamFunc, startTime time.Time) {
if ctx.Stack == nil || !ctx.Stack.IsRoot() || handler == nil {
return
}
// Build the start data
startData := message.EventStreamStartData{
ContextID: ctx.ID,
ChatID: ctx.ChatID,
TraceID: ctx.TraceID(),
RequestID: ctx.RequestID(),
Timestamp: startTime.UnixMilli(),
Assistant: ast.Info(ctx.Locale),
Metadata: ctx.Metadata,
}
if startJSON, err := jsoniter.Marshal(startData); err == nil {
handler(message.ChunkStreamStart, startJSON)
}
}
// sendAgentStreamEnd sends ChunkStreamEnd for root stack only (agent-level stream completion)
func (ast *Assistant) sendAgentStreamEnd(ctx *context.Context, handler message.StreamFunc, startTime time.Time, status string, err error, response *context.CompletionResponse) {
if ctx.Stack == nil || !ctx.Stack.IsRoot() || handler == nil {
return
}
endData := &message.EventStreamEndData{
RequestID: ctx.RequestID(),
ContextID: ctx.ID,
Timestamp: time.Now().UnixMilli(),
DurationMs: time.Since(startTime).Milliseconds(),
Status: status,
TraceID: ctx.TraceID(),
Metadata: ctx.Metadata,
}
if err != nil {
endData.Error = err.Error()
}
if response != nil && response.Usage != nil {
endData.Usage = response.Usage
}
if endJSON, marshalErr := jsoniter.Marshal(endData); marshalErr == nil {
handler(message.ChunkStreamEnd, endJSON)
}
}
// sendStreamEndOnError sends ChunkStreamEnd with error status for root stack only
func (ast *Assistant) sendStreamEndOnError(ctx *context.Context, handler message.StreamFunc, startTime time.Time, err error) {
ast.sendAgentStreamEnd(ctx, handler, startTime, "error", err, nil)
}
// handleInterrupt handles the interrupt signal
// This is called by the interrupt listener when a signal is received
func (ast *Assistant) handleInterrupt(ctx *context.Context, signal *context.InterruptSignal) error {
switch signal.Type {
case context.InterruptForce:
ctx.Logger.Debug("Force interrupt received")
if ctx.Buffer != nil {
ctx.Buffer.FailCurrentStep(context.ResumeStatusInterrupted,
fmt.Errorf("interrupted by user"))
}
case context.InterruptGraceful:
ctx.Logger.Debug("Graceful interrupt received: messages=%d", len(signal.Messages))
}
return nil
}
// initializeCapabilities gets connector and capabilities, then sets them in context
// This should be called early (before sending stream_start) so that output adapters
// can use capabilities when converting stream_start event
func (ast *Assistant) initializeCapabilities(ctx *context.Context, opts *context.Options) error {
if ast.Prompts == nil && ast.MCP == nil {
return nil
}
_, capabilities, err := ast.GetConnector(ctx, opts)
if err != nil {
return err
}
// Set capabilities in context for output adapters to use
if capabilities != nil {
ctx.Capabilities = capabilities
}
return nil
}
// buildToolRetryMessages builds messages for LLM retry with tool call results
// Format follows OpenAI's tool call response pattern:
// 1. Assistant message with tool calls
// 2. Tool messages with results (one per tool call)
// 3. System message explaining the retry
func (ast *Assistant) buildToolRetryMessages(
previousMessages []context.Message,
completionResponse *context.CompletionResponse,
toolResults []ToolCallResult,
) []context.Message {
retryMessages := make([]context.Message, 0, len(previousMessages)+len(toolResults)+2)
// Add all previous messages
retryMessages = append(retryMessages, previousMessages...)
// Add assistant message with tool calls
assistantMsg := context.Message{
Role: context.RoleAssistant,
Content: completionResponse.Content,
ReasoningContent: completionResponse.ReasoningContent,
ToolCalls: completionResponse.ToolCalls,
}
retryMessages = append(retryMessages, assistantMsg)
// Add tool result messages (one per tool call)
for _, result := range toolResults {
toolMsg := context.Message{
Role: context.RoleTool,
Content: result.Content,
ToolCallID: &result.ToolCallID,
}
// Add tool name if available
if result.Name != "" {
name := result.Name
toolMsg.Name = &name
}
retryMessages = append(retryMessages, toolMsg)
}
// Add system message explaining the retry (optional, helps LLM understand context)
systemMsg := context.Message{
Role: context.RoleSystem,
Content: i18n.Tr(ast.ID, "en", "assistant.agent.tool_retry_prompt"),
}
retryMessages = append(retryMessages, systemMsg)
return retryMessages
}

View file

@ -1,398 +0,0 @@
package assistant_test
import (
stdContext "context"
"fmt"
"testing"
"time"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newTestContextWithInterrupt creates a Context with interrupt controller for testing
// Returns the context and a cancel function that should be called before Release()
func newTestContextWithInterrupt(chatID, assistantID string) (*context.Context, stdContext.CancelFunc) {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
ClientID: "test-client-id",
UserID: "test-user-123",
TeamID: "test-team-456",
TenantID: "test-tenant-789",
SessionID: "test-session-id",
}
// Use cancellable context to properly stop goroutines on timeout
parentCtx, cancel := stdContext.WithCancel(stdContext.Background())
ctx := context.New(parentCtx, authorized, chatID)
ctx.ID = fmt.Sprintf("test_ctx_%d", time.Now().UnixNano())
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "TestAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = "/test/route"
ctx.IDGenerator = message.NewIDGenerator() // Initialize context-scoped ID generator
ctx.Metadata = map[string]interface{}{
"test": "interrupt_test",
}
// Initialize interrupt controller
ctx.Interrupt = context.NewInterruptController()
// Register context globally
if err := context.Register(ctx); err != nil {
panic(fmt.Sprintf("Failed to register context: %v", err))
}
// Start interrupt listener
ctx.Interrupt.Start(ctx.ID)
return ctx, cancel
}
// TestAgentInterruptGraceful tests graceful interrupt during agent stream
func TestAgentInterruptGraceful(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.interrupt")
if err != nil {
t.Skipf("Skipping test: assistant 'tests.interrupt' not found: %v", err)
return
}
t.Run("GracefulInterruptDuringStream", func(t *testing.T) {
// Create context with interrupt support
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-graceful", "tests.interrupt")
defer func() {
cancel() // Cancel context first to stop goroutines
time.Sleep(100 * time.Millisecond) // Wait for goroutines to exit
ctx.Release()
}()
// Track handler invocations
handlerInvoked := false
var receivedSignal *context.InterruptSignal
// Override the handler to track invocations
originalHandler := ctx.Interrupt
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
handlerInvoked = true
receivedSignal = signal
t.Logf("✓ Interrupt handler invoked: type=%s, messages=%d", signal.Type, len(signal.Messages))
return nil
})
inputMessages := []context.Message{
{Role: context.RoleUser, Content: "Tell me a long story about artificial intelligence"},
}
// Start streaming in a goroutine
streamDone := make(chan error, 1)
go func() {
_, err := agent.Stream(ctx, inputMessages)
streamDone <- err
}()
// Wait a bit to ensure stream has started
time.Sleep(300 * time.Millisecond)
// Send graceful interrupt signal
signal := &context.InterruptSignal{
Type: context.InterruptGraceful,
Messages: []context.Message{
{Role: context.RoleUser, Content: "Actually, can you make it shorter?"},
},
Timestamp: time.Now().UnixMilli(),
}
err = context.SendInterrupt(ctx.ID, signal)
if err != nil {
t.Logf("Warning: Failed to send interrupt (stream may have completed): %v", err)
} else {
t.Log("✓ Graceful interrupt signal sent")
}
// Wait for stream to complete (with timeout)
select {
case err := <-streamDone:
if err != nil {
t.Logf("Stream completed with error: %v", err)
} else {
t.Log("✓ Stream completed successfully")
}
case <-time.After(10 * time.Second):
t.Log("Stream timeout (expected for real LLM calls)")
cancel() // Cancel to stop the stream goroutine
<-streamDone // Wait for goroutine to exit
}
// Verify handler was invoked if signal was sent
if originalHandler != nil {
time.Sleep(200 * time.Millisecond) // Wait for async handler
if handlerInvoked {
t.Log("✓ Interrupt handler was invoked")
if receivedSignal != nil && receivedSignal.Type == context.InterruptGraceful {
t.Log("✓ Received graceful interrupt signal")
}
}
}
})
}
// TestAgentInterruptForce tests force interrupt during agent stream
func TestAgentInterruptForce(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.interrupt")
if err != nil {
t.Skipf("Skipping test: assistant 'tests.interrupt' not found: %v", err)
return
}
t.Run("ForceInterruptDuringStream", func(t *testing.T) {
// Create context with interrupt support
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-force", "tests.interrupt")
defer func() {
cancel() // Cancel context first to stop goroutines
time.Sleep(100 * time.Millisecond) // Wait for goroutines to exit
ctx.Release()
}()
// Track handler invocations
handlerInvoked := false
streamInterrupted := false
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
handlerInvoked = true
t.Logf("✓ Interrupt handler invoked: type=%s", signal.Type)
return nil
})
inputMessages := []context.Message{
{Role: context.RoleUser, Content: "Write a very detailed essay about machine learning"},
}
// Start streaming in a goroutine
streamDone := make(chan error, 1)
go func() {
_, err := agent.Stream(ctx, inputMessages)
streamDone <- err
}()
// Wait a bit to ensure stream has started
time.Sleep(300 * time.Millisecond)
// Send force interrupt signal
signal := &context.InterruptSignal{
Type: context.InterruptForce,
Messages: []context.Message{
{Role: context.RoleUser, Content: "Stop! I need something else now."},
},
Timestamp: time.Now().UnixMilli(),
}
err = context.SendInterrupt(ctx.ID, signal)
if err != nil {
t.Logf("Warning: Failed to send interrupt: %v", err)
} else {
t.Log("✓ Force interrupt signal sent")
}
// Wait for stream to complete or be interrupted
select {
case err := <-streamDone:
if err != nil {
// Check if error is due to interrupt
if err.Error() == "force interrupted by user" ||
err.Error() == "interrupted by user" ||
err.Error() == "interrupted by user before stream start" {
streamInterrupted = true
t.Logf("✓ Stream was interrupted: %v", err)
} else {
t.Logf("Stream completed with error: %v", err)
}
} else {
t.Log("Stream completed without error")
}
case <-time.After(10 * time.Second):
t.Log("Stream timeout")
cancel() // Cancel to stop the stream goroutine
<-streamDone // Wait for goroutine to exit
}
// Verify interrupt behavior
time.Sleep(200 * time.Millisecond)
if handlerInvoked {
t.Log("✓ Force interrupt handler was invoked")
}
if streamInterrupted {
t.Log("✓ Stream was interrupted by force signal")
}
})
}
// TestAgentMultipleInterrupts tests multiple interrupts during stream
func TestAgentMultipleInterrupts(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.interrupt")
if err != nil {
t.Skipf("Skipping test: assistant 'tests.interrupt' not found: %v", err)
return
}
t.Run("MultipleGracefulInterrupts", func(t *testing.T) {
// Create context with interrupt support
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-multiple", "tests.interrupt")
defer func() {
cancel() // Cancel context first to stop goroutines
time.Sleep(100 * time.Millisecond) // Wait for goroutines to exit
ctx.Release()
}()
handlerCallCount := 0
ctx.Interrupt.SetHandler(func(c *context.Context, signal *context.InterruptSignal) error {
handlerCallCount++
t.Logf("✓ Interrupt handler invoked (call %d): %d messages", handlerCallCount, len(signal.Messages))
return nil
})
inputMessages := []context.Message{
{Role: context.RoleUser, Content: "Explain quantum computing in detail"},
}
// Start streaming
streamDone := make(chan error, 1)
go func() {
_, err := agent.Stream(ctx, inputMessages)
streamDone <- err
}()
// Wait for stream to start
time.Sleep(300 * time.Millisecond)
// Send multiple graceful interrupts
for i := 1; i <= 3; i++ {
signal := &context.InterruptSignal{
Type: context.InterruptGraceful,
Messages: []context.Message{
{Role: context.RoleUser, Content: fmt.Sprintf("Additional question %d", i)},
},
Timestamp: time.Now().UnixMilli(),
}
err = context.SendInterrupt(ctx.ID, signal)
if err != nil {
t.Logf("Warning: Failed to send interrupt %d: %v", i, err)
} else {
t.Logf("✓ Sent interrupt %d", i)
}
time.Sleep(100 * time.Millisecond)
}
// Wait for stream to complete
select {
case err := <-streamDone:
if err != nil {
t.Logf("Stream completed with error: %v", err)
}
case <-time.After(10 * time.Second):
t.Log("Stream timeout")
cancel() // Cancel to stop the stream goroutine
<-streamDone // Wait for goroutine to exit
}
// Check if interrupts were received
time.Sleep(300 * time.Millisecond)
pendingCount := ctx.Interrupt.GetPendingCount()
t.Logf("Handler was called %d times, pending count: %d", handlerCallCount, pendingCount)
if handlerCallCount > 0 {
t.Log("✓ Multiple interrupts were processed")
}
})
}
// TestAgentInterruptWithoutStream tests interrupt behavior when no stream is active
func TestAgentInterruptWithoutStream(t *testing.T) {
t.Run("InterruptBeforeStream", func(t *testing.T) {
// Create context with interrupt support
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-before", "test-assistant")
defer func() {
cancel()
ctx.Release()
}()
// Send interrupt before starting stream
signal := &context.InterruptSignal{
Type: context.InterruptGraceful,
Messages: []context.Message{
{Role: context.RoleUser, Content: "Early interrupt"},
},
Timestamp: time.Now().UnixMilli(),
}
err := context.SendInterrupt(ctx.ID, signal)
if err != nil {
t.Fatalf("Failed to send interrupt: %v", err)
}
// Wait for signal to be processed
time.Sleep(100 * time.Millisecond)
// Check if signal is in queue
receivedSignal := ctx.Interrupt.Peek()
if receivedSignal == nil {
t.Fatal("Expected interrupt signal to be queued")
}
if receivedSignal.Type != context.InterruptGraceful {
t.Errorf("Expected graceful interrupt, got: %s", receivedSignal.Type)
}
t.Log("✓ Interrupt queued before stream starts")
})
}
// TestAgentInterruptContextCleanup tests cleanup after interrupt
func TestAgentInterruptContextCleanup(t *testing.T) {
t.Run("CleanupAfterInterrupt", func(t *testing.T) {
ctx, cancel := newTestContextWithInterrupt("chat-interrupt-cleanup", "test-assistant")
// Send interrupt
signal := &context.InterruptSignal{
Type: context.InterruptGraceful,
Messages: []context.Message{{Role: context.RoleUser, Content: "test"}},
Timestamp: time.Now().UnixMilli(),
}
context.SendInterrupt(ctx.ID, signal)
time.Sleep(100 * time.Millisecond)
// Cancel and release context
cancel()
ctx.Release()
// Try to send interrupt to released context
err := context.SendInterrupt(ctx.ID, signal)
if err == nil {
t.Error("Expected error when sending to released context")
} else {
t.Logf("✓ Correctly rejected interrupt to released context: %v", err)
}
})
}

View file

@ -1,236 +0,0 @@
package assistant_test
import (
stdContext "context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newAgentNextTestContext creates a test context
func newAgentNextTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Client = context.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator() // Initialize ID generator
ctx.Metadata = make(map[string]interface{})
return ctx
}
// TestAgentNextStandard tests agent with Next Hook returning nil (standard response)
func TestAgentNextStandard(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
assert.NoError(t, err)
ctx := newAgentNextTestContext("test-standard", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: standard - Hello"},
}
response, err := agent.Stream(ctx, messages)
assert.NoError(t, err)
assert.NotNil(t, response)
assert.NotNil(t, response.Completion)
assert.Nil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
assert.NotEmpty(t, response.ChatID)
// Verify completion has content
assert.NotNil(t, response.Completion.Content)
t.Log("✓ Standard response test passed")
}
// TestAgentNextCustomData tests agent with Next Hook returning custom data
func TestAgentNextCustomData(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
assert.NoError(t, err)
ctx := newAgentNextTestContext("test-custom", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: custom_data - Give me info"},
}
response, err := agent.Stream(ctx, messages)
assert.NoError(t, err)
assert.NotNil(t, response)
assert.NotNil(t, response.Completion)
assert.NotNil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
// Verify custom data structure (from scenarioCustomData)
// response.Next contains the "data" field value from NextHookResponse
nextData, ok := response.Next.(map[string]interface{})
assert.True(t, ok, "Next should be a map")
assert.Equal(t, "custom_response", nextData["type"])
assert.Equal(t, "This is a custom response from Next Hook", nextData["message"])
assert.NotEmpty(t, nextData["timestamp"])
assert.NotNil(t, nextData["message_count"])
t.Log("✓ Custom data test passed")
}
// TestAgentNextDelegate tests agent with Next Hook delegating to another agent
func TestAgentNextDelegate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
assert.NoError(t, err)
ctx := newAgentNextTestContext("test-delegate", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: delegate - Forward this"},
}
response, err := agent.Stream(ctx, messages)
assert.NoError(t, err)
assert.NotNil(t, response)
// Verify response structure
assert.NotEmpty(t, response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
// Verify completion (delegated agent should have returned completion)
assert.NotNil(t, response.Completion)
assert.NotNil(t, response.Completion.Content)
// Next should be from the delegated agent
// If delegated agent also has Next hook, it will be present
t.Logf("✓ Delegation test passed (delegated to: %s)", response.AssistantID)
}
// TestAgentNextConditional tests agent with conditional logic in Next Hook
func TestAgentNextConditional(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
assert.NoError(t, err)
ctx := newAgentNextTestContext("test-conditional", "tests.realworld-next")
messages := []context.Message{
// Use conditional_success sub-scenario for deterministic behavior
// This avoids test flakiness caused by LLM response unpredictability
{Role: context.RoleUser, Content: "scenario: conditional_success - Task completed"},
}
response, err := agent.Stream(ctx, messages)
assert.NoError(t, err)
assert.NotNil(t, response)
assert.NotNil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.realworld-next", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
// Verify conditional response structure (from scenarioConditional)
// response.Next contains the "data" field value from NextHookResponse
nextData, ok := response.Next.(map[string]interface{})
assert.True(t, ok, "Next should be a map")
assert.Equal(t, "Conditional analysis complete", nextData["message"])
assert.Contains(t, nextData, "action")
assert.Contains(t, nextData, "reason")
assert.Contains(t, nextData, "conditions")
// Verify action is one of the expected values
action, ok := nextData["action"].(string)
assert.True(t, ok)
assert.Contains(t, []string{"continue", "flag_for_review", "confirm_success", "summarize", "delegate"}, action)
t.Log("✓ Conditional logic test passed")
}
// TestAgentWithoutNextHook tests agent without Next Hook
func TestAgentWithoutNextHook(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
assert.NoError(t, err)
ctx := newAgentNextTestContext("test-no-next", "tests.create")
messages := []context.Message{
{Role: context.RoleUser, Content: "Hello"},
}
response, err := agent.Stream(ctx, messages)
assert.NoError(t, err)
assert.NotNil(t, response)
assert.Nil(t, response.Next)
// Verify response structure
assert.Equal(t, "tests.create", response.AssistantID)
assert.NotEmpty(t, response.ContextID)
assert.NotEmpty(t, response.RequestID)
assert.NotEmpty(t, response.TraceID)
assert.NotEmpty(t, response.ChatID)
// Verify completion
assert.NotNil(t, response.Completion)
assert.NotNil(t, response.Completion.Content)
t.Log("✓ No Next Hook test passed")
}

View file

@ -1,704 +0,0 @@
package assistant
import (
"fmt"
"path"
"strings"
"github.com/yaoapp/gou/fs"
"github.com/yaoapp/yao/agent/caller"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/agent/search"
searchTypes "github.com/yaoapp/yao/agent/search/types"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/openapi/utils"
sui "github.com/yaoapp/yao/sui/core"
)
func init() {
// Initialize AgentGetterFunc to allow content and search packages to call agents
caller.AgentGetterFunc = func(agentID string) (caller.AgentCaller, error) {
ast, err := Get(agentID)
if err != nil {
return nil, err
}
// Return a wrapper that implements AgentCaller interface
return &agentCallerWrapper{ast: ast}, nil
}
// Initialize AssistantReloadFunc for hot-reload after deploy
caller.AssistantReloadFunc = func(id string) error {
p := "/assistants/" + strings.Replace(id, ".", "/", 1)
ast, err := LoadPath(p)
if err != nil {
return err
}
ast.BuiltIn = true
ast.Readonly = true
if ast.Tags == nil {
ast.Tags = []string{}
}
if err := ast.Save(); err != nil {
return err
}
if err := ast.initialize(); err != nil {
return err
}
loaded.Put(ast)
return nil
}
// Initialize Agent JSAPI factory for ctx.agent.* methods
caller.SetJSAPIFactory()
// Initialize LLM JSAPI factory for ctx.llm.* methods
llm.SetJSAPIFactory()
// Initialize Search JSAPI factory with config getter
search.SetJSAPIFactory(func(assistantID string) (*searchTypes.Config, *search.Uses) {
ast, err := Get(assistantID)
if err != nil || ast == nil {
return nil, nil
}
// Convert assistant.Uses to search.Uses
var uses *search.Uses
if ast.Uses != nil {
uses = &search.Uses{
Search: ast.Uses.Search,
Web: ast.Uses.Web,
Keyword: ast.Uses.Keyword,
QueryDSL: ast.Uses.QueryDSL,
Rerank: ast.Uses.Rerank,
}
}
return ast.Search, uses
})
}
// agentCallerWrapper wraps Assistant to implement AgentCaller interface
type agentCallerWrapper struct {
ast *Assistant
}
func (w *agentCallerWrapper) Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (*agentContext.Response, error) {
return w.ast.Stream(ctx, messages, options...)
}
// Get get the assistant by id
func Get(id string) (*Assistant, error) {
return LoadStore(id)
}
// GetPlaceholder returns the placeholder of the assistant
func (ast *Assistant) GetPlaceholder(locale string) *store.Placeholder {
prompts := []string{}
if ast.Placeholder.Prompts != nil {
prompts = i18n.Translate(ast.ID, locale, ast.Placeholder.Prompts).([]string)
}
title := i18n.Translate(ast.ID, locale, ast.Placeholder.Title).(string)
description := i18n.Translate(ast.ID, locale, ast.Placeholder.Description).(string)
return &store.Placeholder{
Title: title,
Description: description,
Prompts: prompts,
}
}
// GetName returns the name of the assistant
func (ast *Assistant) GetName(locale string) string {
return i18n.Translate(ast.ID, locale, ast.Name).(string)
}
// GetDescription returns the description of the assistant
func (ast *Assistant) GetDescription(locale string) string {
return i18n.Translate(ast.ID, locale, ast.Description).(string)
}
// Save save the assistant
func (ast *Assistant) Save() error {
if storage == nil {
return fmt.Errorf("storage is not set")
}
_, err := storage.SaveAssistant(&ast.AssistantModel)
if err != nil {
return err
}
return nil
}
// Map convert the assistant to a map
func (ast *Assistant) Map() map[string]interface{} {
if ast == nil {
return nil
}
return map[string]interface{}{
"assistant_id": ast.ID,
"type": ast.Type,
"name": ast.Name,
"readonly": ast.Readonly,
"public": ast.Public,
"share": ast.Share,
"avatar": ast.Avatar,
"connector": ast.Connector,
"connector_options": ast.ConnectorOptions,
"path": ast.Path,
"built_in": ast.BuiltIn,
"sort": ast.Sort,
"description": ast.Description,
"options": ast.Options,
"prompts": ast.Prompts,
"prompt_presets": ast.PromptPresets,
"disable_global_prompts": ast.DisableGlobalPrompts,
"source": ast.Source,
"kb": ast.KB,
"db": ast.DB,
"mcp": ast.MCP,
"workflow": ast.Workflow,
"tags": ast.Tags,
"modes": ast.Modes,
"default_mode": ast.DefaultMode,
"mentionable": ast.Mentionable,
"automated": ast.Automated,
"placeholder": ast.Placeholder,
"locales": ast.Locales,
"uses": ast.Uses,
"search": ast.Search,
"dependencies": ast.Dependencies,
"created_at": utils.NanoToTime(ast.CreatedAt),
"updated_at": utils.NanoToTime(ast.UpdatedAt),
}
}
// Validate validates the assistant configuration
func (ast *Assistant) Validate() error {
if ast.ID == "" {
return fmt.Errorf("assistant_id is required")
}
if ast.Name == "" {
return fmt.Errorf("name is required")
}
return nil
}
// Assets get the assets content
func (ast *Assistant) Assets(name string, data sui.Data) (string, error) {
app, err := fs.Get("app")
if err != nil {
return "", err
}
root := path.Join(ast.Path, "assets", name)
raw, err := app.ReadFile(root)
if err != nil {
return "", err
}
if data != nil {
content, _ := data.Replace(string(raw))
return content, nil
}
return string(raw), nil
}
// Clone creates a deep copy of the assistant
func (ast *Assistant) Clone() *Assistant {
if ast == nil {
return nil
}
clone := &Assistant{
AssistantModel: store.AssistantModel{
ID: ast.ID,
Type: ast.Type,
Name: ast.Name,
Avatar: ast.Avatar,
Connector: ast.Connector,
Path: ast.Path,
BuiltIn: ast.BuiltIn,
Sort: ast.Sort,
Description: ast.Description,
Readonly: ast.Readonly,
Public: ast.Public,
Share: ast.Share,
Mentionable: ast.Mentionable,
Automated: ast.Automated,
DisableGlobalPrompts: ast.DisableGlobalPrompts,
Source: ast.Source,
CreatedAt: ast.CreatedAt,
UpdatedAt: ast.UpdatedAt,
},
HookScript: ast.HookScript,
}
// Deep copy tags
if ast.Tags != nil {
clone.Tags = make([]string, len(ast.Tags))
copy(clone.Tags, ast.Tags)
}
// Deep copy modes
if ast.Modes != nil {
clone.Modes = make([]string, len(ast.Modes))
copy(clone.Modes, ast.Modes)
}
// Copy default_mode (simple string)
clone.DefaultMode = ast.DefaultMode
// Deep copy KB
if ast.KB != nil {
clone.KB = &store.KnowledgeBase{}
if ast.KB.Collections != nil {
clone.KB.Collections = make([]string, len(ast.KB.Collections))
copy(clone.KB.Collections, ast.KB.Collections)
}
if ast.KB.Options != nil {
clone.KB.Options = make(map[string]interface{})
for k, v := range ast.KB.Options {
clone.KB.Options[k] = v
}
}
}
// Deep copy DB
if ast.DB != nil {
clone.DB = &store.Database{}
if ast.DB.Models != nil {
clone.DB.Models = make([]string, len(ast.DB.Models))
copy(clone.DB.Models, ast.DB.Models)
}
if ast.DB.Options != nil {
clone.DB.Options = make(map[string]interface{})
for k, v := range ast.DB.Options {
clone.DB.Options[k] = v
}
}
}
// Deep copy MCP
if ast.MCP != nil {
clone.MCP = &store.MCPServers{}
if ast.MCP.Servers != nil {
clone.MCP.Servers = make([]store.MCPServerConfig, len(ast.MCP.Servers))
for i, server := range ast.MCP.Servers {
clone.MCP.Servers[i] = store.MCPServerConfig{
ServerID: server.ServerID,
}
// Deep copy Resources slice
if server.Resources != nil {
clone.MCP.Servers[i].Resources = make([]string, len(server.Resources))
copy(clone.MCP.Servers[i].Resources, server.Resources)
}
// Deep copy Tools slice
if server.Tools != nil {
clone.MCP.Servers[i].Tools = make([]string, len(server.Tools))
copy(clone.MCP.Servers[i].Tools, server.Tools)
}
}
}
if ast.MCP.Options != nil {
clone.MCP.Options = make(map[string]interface{})
for k, v := range ast.MCP.Options {
clone.MCP.Options[k] = v
}
}
}
// Deep copy options
if ast.Options != nil {
clone.Options = make(map[string]interface{})
for k, v := range ast.Options {
clone.Options[k] = v
}
}
// Deep copy prompts
if ast.Prompts != nil {
clone.Prompts = make([]store.Prompt, len(ast.Prompts))
copy(clone.Prompts, ast.Prompts)
}
// Deep copy prompt presets
if ast.PromptPresets != nil {
clone.PromptPresets = make(map[string][]store.Prompt)
for k, v := range ast.PromptPresets {
prompts := make([]store.Prompt, len(v))
copy(prompts, v)
clone.PromptPresets[k] = prompts
}
}
// Deep copy connector options
if ast.ConnectorOptions != nil {
clone.ConnectorOptions = &store.ConnectorOptions{
Optional: ast.ConnectorOptions.Optional,
}
if ast.ConnectorOptions.Connectors != nil {
clone.ConnectorOptions.Connectors = make([]string, len(ast.ConnectorOptions.Connectors))
copy(clone.ConnectorOptions.Connectors, ast.ConnectorOptions.Connectors)
}
if ast.ConnectorOptions.Filters != nil {
clone.ConnectorOptions.Filters = make([]store.ModelCapability, len(ast.ConnectorOptions.Filters))
copy(clone.ConnectorOptions.Filters, ast.ConnectorOptions.Filters)
}
}
// Deep copy workflow
if ast.Workflow != nil {
clone.Workflow = &store.Workflow{}
if ast.Workflow.Workflows != nil {
clone.Workflow.Workflows = make([]string, len(ast.Workflow.Workflows))
copy(clone.Workflow.Workflows, ast.Workflow.Workflows)
}
if ast.Workflow.Options != nil {
clone.Workflow.Options = make(map[string]interface{})
for k, v := range ast.Workflow.Options {
clone.Workflow.Options[k] = v
}
}
}
// Deep copy placeholder
if ast.Placeholder != nil {
clone.Placeholder = &store.Placeholder{
Title: ast.Placeholder.Title,
Description: ast.Placeholder.Description,
}
if ast.Placeholder.Prompts != nil {
clone.Placeholder.Prompts = make([]string, len(ast.Placeholder.Prompts))
copy(clone.Placeholder.Prompts, ast.Placeholder.Prompts)
}
}
// Deep copy locales
if ast.Locales != nil {
clone.Locales = make(i18n.Map)
for k, v := range ast.Locales {
// Deep copy messages
messages := make(map[string]any)
if v.Messages != nil {
for mk, mv := range v.Messages {
messages[mk] = mv
}
}
clone.Locales[k] = i18n.I18n{
Locale: v.Locale,
Messages: messages,
}
}
}
// Deep copy uses
if ast.Uses != nil {
clone.Uses = &agentContext.Uses{
Vision: ast.Uses.Vision,
Audio: ast.Uses.Audio,
Search: ast.Uses.Search,
Fetch: ast.Uses.Fetch,
Web: ast.Uses.Web,
Keyword: ast.Uses.Keyword,
QueryDSL: ast.Uses.QueryDSL,
Rerank: ast.Uses.Rerank,
}
}
// Deep copy search config
if ast.Search != nil {
clone.Search = &searchTypes.Config{}
if ast.Search.Web != nil {
clone.Search.Web = &searchTypes.WebConfig{
Provider: ast.Search.Web.Provider,
APIKeyEnv: ast.Search.Web.APIKeyEnv,
MaxResults: ast.Search.Web.MaxResults,
}
}
if ast.Search.KB != nil {
clone.Search.KB = &searchTypes.KBConfig{
Threshold: ast.Search.KB.Threshold,
Graph: ast.Search.KB.Graph,
}
if ast.Search.KB.Collections != nil {
clone.Search.KB.Collections = make([]string, len(ast.Search.KB.Collections))
copy(clone.Search.KB.Collections, ast.Search.KB.Collections)
}
}
if ast.Search.DB != nil {
clone.Search.DB = &searchTypes.DBConfig{
MaxResults: ast.Search.DB.MaxResults,
}
if ast.Search.DB.Models != nil {
clone.Search.DB.Models = make([]string, len(ast.Search.DB.Models))
copy(clone.Search.DB.Models, ast.Search.DB.Models)
}
}
if ast.Search.Keyword != nil {
clone.Search.Keyword = &searchTypes.KeywordConfig{
MaxKeywords: ast.Search.Keyword.MaxKeywords,
Language: ast.Search.Keyword.Language,
}
}
if ast.Search.QueryDSL != nil {
clone.Search.QueryDSL = &searchTypes.QueryDSLConfig{
Strict: ast.Search.QueryDSL.Strict,
}
}
if ast.Search.Rerank != nil {
clone.Search.Rerank = &searchTypes.RerankConfig{
TopN: ast.Search.Rerank.TopN,
}
}
if ast.Search.Citation != nil {
clone.Search.Citation = &searchTypes.CitationConfig{
Format: ast.Search.Citation.Format,
AutoInjectPrompt: ast.Search.Citation.AutoInjectPrompt,
CustomPrompt: ast.Search.Citation.CustomPrompt,
}
}
if ast.Search.Weights != nil {
clone.Search.Weights = &searchTypes.WeightsConfig{
User: ast.Search.Weights.User,
Hook: ast.Search.Weights.Hook,
Auto: ast.Search.Weights.Auto,
}
}
if ast.Search.Options != nil {
clone.Search.Options = &searchTypes.OptionsConfig{
SkipThreshold: ast.Search.Options.SkipThreshold,
}
}
}
// Deep copy dependencies
if ast.Dependencies != nil {
clone.Dependencies = make(map[string]string, len(ast.Dependencies))
for k, v := range ast.Dependencies {
clone.Dependencies[k] = v
}
}
return clone
}
// GetInfo returns the basic info of the assistant with optional locale
func (ast *Assistant) GetInfo(locale ...string) *store.AssistantInfo {
if ast == nil {
return nil
}
loc := ""
if len(locale) > 0 {
loc = locale[0]
}
info := &store.AssistantInfo{
AssistantID: ast.ID,
Avatar: ast.Avatar,
Connector: ast.Connector,
ConnectorOptions: ast.ConnectorOptions,
Modes: ast.Modes,
DefaultMode: ast.DefaultMode,
Sandbox: ast.IsSandbox,
ComputerFilter: ast.ComputerFilter,
}
if loc != "" {
info.Name = ast.GetName(loc)
info.Description = ast.GetDescription(loc)
} else {
info.Name = ast.Name
info.Description = ast.Description
}
return info
}
// GetInfoByIDs retrieves basic info for multiple assistants by their IDs
// Returns a map of assistant_id -> AssistantInfo
func GetInfoByIDs(ids []string, locale ...string) map[string]*store.AssistantInfo {
result := make(map[string]*store.AssistantInfo)
if len(ids) == 0 {
return result
}
for _, id := range ids {
ast, err := Get(id)
if err != nil || ast == nil {
continue
}
result[id] = ast.GetInfo(locale...)
}
return result
}
// Update updates the assistant properties
func (ast *Assistant) Update(data map[string]interface{}) error {
if ast == nil {
return fmt.Errorf("assistant is nil")
}
if v, ok := data["name"].(string); ok {
ast.Name = v
}
if v, ok := data["avatar"].(string); ok {
ast.Avatar = v
}
if v, ok := data["description"].(string); ok {
ast.Description = v
}
if v, ok := data["connector"].(string); ok {
ast.Connector = v
}
// Note: tools field is deprecated, now handled by MCP
if v, ok := data["type"].(string); ok {
ast.Type = v
}
if v, ok := data["sort"].(int); ok {
ast.Sort = v
}
if v, ok := data["mentionable"].(bool); ok {
ast.Mentionable = v
}
if v, ok := data["automated"].(bool); ok {
ast.Automated = v
}
if v, ok := data["disable_global_prompts"].(bool); ok {
ast.DisableGlobalPrompts = v
}
if v, ok := data["readonly"].(bool); ok {
ast.Readonly = v
}
if v, ok := data["public"].(bool); ok {
ast.Public = v
}
if v, ok := data["share"].(string); ok {
ast.Share = v
}
if v, ok := data["tags"].([]string); ok {
ast.Tags = v
}
if v, ok := data["modes"].([]string); ok {
ast.Modes = v
}
if v, ok := data["default_mode"].(string); ok {
ast.DefaultMode = v
}
if v, ok := data["options"].(map[string]interface{}); ok {
ast.Options = v
}
if v, ok := data["source"].(string); ok {
ast.Source = v
}
// ConnectorOptions
if v, has := data["connector_options"]; has {
connOpts, err := store.ToConnectorOptions(v)
if err != nil {
return err
}
ast.ConnectorOptions = connOpts
}
// PromptPresets
if v, has := data["prompt_presets"]; has {
presets, err := store.ToPromptPresets(v)
if err != nil {
return err
}
ast.PromptPresets = presets
}
// KB
if v, has := data["kb"]; has {
kb, err := store.ToKnowledgeBase(v)
if err != nil {
return err
}
ast.KB = kb
}
// DB
if v, has := data["db"]; has {
db, err := store.ToDatabase(v)
if err != nil {
return err
}
ast.DB = db
}
// MCP
if v, has := data["mcp"]; has {
mcp, err := store.ToMCPServers(v)
if err != nil {
return err
}
ast.MCP = mcp
}
// Workflow
if v, has := data["workflow"]; has {
workflow, err := store.ToWorkflow(v)
if err != nil {
return err
}
ast.Workflow = workflow
}
// Uses
if v, has := data["uses"]; has {
uses, err := store.ToUses(v)
if err != nil {
return err
}
ast.Uses = uses
}
// Search
if v, has := data["search"]; has {
search, err := store.ToSearchConfig(v)
if err != nil {
return err
}
ast.Search = search
}
// Dependencies
if v, has := data["dependencies"]; has {
if v == nil {
ast.Dependencies = nil
} else {
switch d := v.(type) {
case map[string]string:
ast.Dependencies = d
case map[string]interface{}:
deps := make(map[string]string, len(d))
for k, val := range d {
if s, ok := val.(string); ok {
deps[k] = s
}
}
ast.Dependencies = deps
}
}
}
return ast.Validate()
}
// GetMergedSearchConfig returns the search config for this assistant
// Note: The config is already merged with global config during loading (loadMap)
func (ast *Assistant) GetMergedSearchConfig() *searchTypes.Config {
return ast.Search
}

View file

@ -1,595 +0,0 @@
package assistant
import (
"fmt"
"github.com/spf13/cast"
"github.com/yaoapp/gou/json"
"github.com/yaoapp/yao/agent/context"
store "github.com/yaoapp/yao/agent/store/types"
)
// BuildRequest build the LLM request
func (ast *Assistant) BuildRequest(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse) ([]context.Message, *context.CompletionOptions, error) {
// Build completion options from createResponse and ctx (includes MCP tools)
options, mcpSamplesPrompt, err := ast.buildCompletionOptions(ctx, createResponse)
if err != nil {
return nil, nil, err
}
// Build final messages with proper priority (includes MCP samples if available)
finalMessages, err := ast.buildMessages(ctx, messages, createResponse, mcpSamplesPrompt)
if err != nil {
return nil, nil, err
}
return finalMessages, options, nil
}
// buildMessages builds the final message list with proper priority
// Priority: Prompts > MCP Samples > createResponse.Messages > input messages
// If createResponse is nil or has no messages, use input messages
func (ast *Assistant) buildMessages(ctx *context.Context, messages []context.Message, createResponse *context.HookCreateResponse, mcpSamplesPrompt string) ([]context.Message, error) {
var finalMessages []context.Message
// If createResponse is nil or has no messages, use input messages
if createResponse == nil || len(createResponse.Messages) == 0 {
finalMessages = messages
} else {
// createResponse.Messages takes priority over input messages
finalMessages = createResponse.Messages
}
// Add MCP samples prompt as a system message (if available)
if mcpSamplesPrompt != "" {
mcpSamplesMsg := context.Message{
Role: context.RoleSystem,
Content: mcpSamplesPrompt,
}
// Prepend MCP samples before other messages
finalMessages = append([]context.Message{mcpSamplesMsg}, finalMessages...)
}
// Build and prepend system prompts (global + assistant prompts)
promptMessages := ast.buildSystemPrompts(ctx, createResponse)
if len(promptMessages) > 0 {
finalMessages = append(promptMessages, finalMessages...)
}
return finalMessages, nil
}
// buildSystemPrompts builds system prompt messages from global prompts and assistant prompts
// Order: Global prompts (if not disabled) -> Assistant prompts (or preset)
// Variables are parsed with context information
//
// Priority for prompt preset selection:
// 1. createResponse.PromptPreset (highest)
// 2. ctx.Metadata["__prompt_preset"]
// 3. ast.Prompts (default)
//
// Priority for disable global prompts:
// 1. createResponse.DisableGlobalPrompts (highest)
// 2. ctx.Metadata["__disable_global_prompts"]
// 3. ast.DisableGlobalPrompts (default)
func (ast *Assistant) buildSystemPrompts(ctx *context.Context, createResponse *context.HookCreateResponse) []context.Message {
// Build context variables from ctx and ast
ctxVars := ast.buildContextVariables(ctx)
// Determine if global prompts should be disabled
disableGlobal := ast.shouldDisableGlobalPrompts(ctx, createResponse)
// Get assistant prompts (default or preset)
assistantPrompts := ast.getAssistantPrompts(ctx, createResponse)
var allPrompts []store.Prompt
// 1. Add global prompts (if not disabled)
if !disableGlobal && len(globalPrompts) > 0 {
// Parse global prompts with context variables
parsedGlobal := store.Prompts(globalPrompts).Parse(ctxVars)
allPrompts = append(allPrompts, parsedGlobal...)
}
// 2. Add assistant prompts (default or preset)
if len(assistantPrompts) > 0 {
// Parse assistant prompts with context variables
parsedAssistant := store.Prompts(assistantPrompts).Parse(ctxVars)
allPrompts = append(allPrompts, parsedAssistant...)
}
// Convert to context.Message slice
if len(allPrompts) == 0 {
return nil
}
messages := make([]context.Message, 0, len(allPrompts))
for _, prompt := range allPrompts {
msg := context.Message{
Role: context.MessageRole(prompt.Role),
Content: prompt.Content,
}
if prompt.Name != "" {
name := prompt.Name
msg.Name = &name
}
messages = append(messages, msg)
}
return messages
}
// shouldDisableGlobalPrompts determines if global prompts should be disabled
// Priority: createResponse > ctx.Metadata > ast.DisableGlobalPrompts
func (ast *Assistant) shouldDisableGlobalPrompts(ctx *context.Context, createResponse *context.HookCreateResponse) bool {
// Priority 1: Hook response (highest)
if createResponse != nil && createResponse.DisableGlobalPrompts != nil {
return *createResponse.DisableGlobalPrompts
}
// Priority 2: ctx.Metadata["__disable_global_prompts"]
if ctx != nil && ctx.Metadata != nil {
if disable, ok := ctx.Metadata["__disable_global_prompts"].(bool); ok {
return disable
}
}
// Priority 3: Assistant configuration (default)
return ast.DisableGlobalPrompts
}
// getAssistantPrompts returns the assistant prompts based on preset selection
// Priority: createResponse.PromptPreset > ctx.Metadata["__prompt_preset"] > ast.Prompts
func (ast *Assistant) getAssistantPrompts(ctx *context.Context, createResponse *context.HookCreateResponse) []store.Prompt {
// Get preset key
presetKey := ast.getPromptPresetKey(ctx, createResponse)
// If preset key is specified and exists, use it
if presetKey != "" && ast.PromptPresets != nil {
if presets, ok := ast.PromptPresets[presetKey]; ok && len(presets) > 0 {
return presets
}
}
// Fallback to default prompts
return ast.Prompts
}
// getPromptPresetKey returns the prompt preset key
// Priority: createResponse.PromptPreset > ctx.Metadata["__prompt_preset"]
func (ast *Assistant) getPromptPresetKey(ctx *context.Context, createResponse *context.HookCreateResponse) string {
// Priority 1: Hook response (highest)
if createResponse != nil && createResponse.PromptPreset != "" {
return createResponse.PromptPreset
}
// Priority 2: ctx.Metadata["__prompt_preset"]
if ctx != nil && ctx.Metadata != nil {
if preset, ok := ctx.Metadata["__prompt_preset"].(string); ok && preset != "" {
return preset
}
}
// No preset specified
return ""
}
// buildContextVariables extracts context variables from Context and Assistant for prompt parsing
func (ast *Assistant) buildContextVariables(ctx *context.Context) map[string]string {
vars := make(map[string]string)
// Get locale from ctx (default to empty)
locale := ""
if ctx != nil && ctx.Locale != "" {
locale = ctx.Locale
}
// Assistant info (with locale support)
if ast != nil {
if ast.ID != "" {
vars["ASSISTANT_ID"] = ast.ID
}
// Use localized name and description
name := ast.GetName(locale)
if name != "" {
vars["ASSISTANT_NAME"] = name
}
description := ast.GetDescription(locale)
if description != "" {
vars["ASSISTANT_DESCRIPTION"] = description
}
if ast.Type != "" {
vars["ASSISTANT_TYPE"] = ast.Type
}
}
// Workspace info
if workspaceID, err := ctx.GetWorkspaceID(); err == nil {
vars["WORKSPACE_ID"] = workspaceID
}
if ctx == nil {
return vars
}
// Basic context info
if ctx.ChatID != "" {
vars["CHAT_ID"] = ctx.ChatID
}
if ctx.Locale != "" {
vars["LOCALE"] = ctx.Locale
}
if ctx.Theme != "" {
vars["THEME"] = ctx.Theme
}
if ctx.Route != "" {
vars["ROUTE"] = ctx.Route
}
if ctx.Referer != "" {
vars["REFERER"] = ctx.Referer
}
// Client info (only non-sensitive fields)
if ctx.Client.Type != "" {
vars["CLIENT_TYPE"] = ctx.Client.Type
}
// Authorized info (only internal IDs, no PII)
// Note: USER_SUBJECT and CLIENT_IP are excluded for privacy/GDPR compliance
if ctx.Authorized != nil {
if ctx.Authorized.UserID != "" {
vars["USER_ID"] = ctx.Authorized.UserID
}
if ctx.Authorized.TeamID != "" {
vars["TEAM_ID"] = ctx.Authorized.TeamID
}
if ctx.Authorized.TenantID != "" {
vars["TENANT_ID"] = ctx.Authorized.TenantID
}
}
// Metadata - custom variables from ctx.Metadata
// All metadata keys are exposed as $CTX.{KEY}
// Supports string, int, uint, float, bool types
if ctx.Metadata != nil {
for key, value := range ctx.Metadata {
if value == nil {
continue
}
strVal := cast.ToString(value)
if strVal != "" {
vars[key] = strVal
}
}
}
return vars
}
// buildCompletionOptions builds completion options from multiple sources
// Priority (lowest to highest, later overrides earlier): ast > ctx > createResponse
// The priority means: if createResponse has a value, use it; else use ctx; else use ast
// Returns (options, mcpSamplesPrompt, error)
func (ast *Assistant) buildCompletionOptions(ctx *context.Context, createResponse *context.HookCreateResponse) (*context.CompletionOptions, string, error) {
options := &context.CompletionOptions{}
// Layer 1 (base): Apply ast - Assistant configuration
if err := ast.applyAssistantOptions(options); err != nil {
return nil, "", err
}
// Layer 2 (middle): Apply ctx - Context configuration (overrides ast)
ast.applyContextOptions(options, ctx)
// Layer 3 (highest): Apply createResponse - Hook configuration (overrides all)
if createResponse != nil {
ast.applyCreateResponseOptions(options, createResponse)
}
// Add MCP tools if configured and get samples prompt
mcpSamplesPrompt, err := ast.applyMCPTools(ctx, options, createResponse)
if err != nil {
return nil, "", fmt.Errorf("failed to apply MCP tools: %w", err)
}
return options, mcpSamplesPrompt, nil
}
// applyAssistantOptions applies options from ast.Options to CompletionOptions
// ast.Options can contain any OpenAI API parameters (temperature, top_p, stop, etc.)
// Returns error if any option validation fails (e.g., invalid JSON Schema)
func (ast *Assistant) applyAssistantOptions(options *context.CompletionOptions) error {
if ast.Options == nil {
return nil
}
// Temperature
if v, ok := ast.Options["temperature"].(float64); ok {
options.Temperature = &v
}
// MaxTokens
if v, ok := ast.Options["max_tokens"].(float64); ok {
intVal := int(v)
options.MaxTokens = &intVal
} else if v, ok := ast.Options["max_tokens"].(int); ok {
options.MaxTokens = &v
}
// MaxCompletionTokens
if v, ok := ast.Options["max_completion_tokens"].(float64); ok {
intVal := int(v)
options.MaxCompletionTokens = &intVal
} else if v, ok := ast.Options["max_completion_tokens"].(int); ok {
options.MaxCompletionTokens = &v
}
// TopP
if v, ok := ast.Options["top_p"].(float64); ok {
options.TopP = &v
}
// N (number of choices)
if v, ok := ast.Options["n"].(float64); ok {
intVal := int(v)
options.N = &intVal
} else if v, ok := ast.Options["n"].(int); ok {
options.N = &v
}
// Stop sequences (can be string or []string)
if v, ok := ast.Options["stop"]; ok {
options.Stop = v
}
// PresencePenalty
if v, ok := ast.Options["presence_penalty"].(float64); ok {
options.PresencePenalty = &v
}
// FrequencyPenalty
if v, ok := ast.Options["frequency_penalty"].(float64); ok {
options.FrequencyPenalty = &v
}
// LogitBias
if v, ok := ast.Options["logit_bias"].(map[string]interface{}); ok {
logitBias := make(map[string]float64)
for key, val := range v {
if fval, ok := val.(float64); ok {
logitBias[key] = fval
}
}
if len(logitBias) > 0 {
options.LogitBias = logitBias
}
}
// User
if v, ok := ast.Options["user"].(string); ok {
options.User = v
}
// ResponseFormat
// @todo: Assistant should have a default response format
if v, ok := ast.Options["response_format"]; ok {
// Try to convert to *context.ResponseFormat
if rf, ok := v.(*context.ResponseFormat); ok {
// Validate JSONSchema if present - reject if invalid
if rf.JSONSchema != nil && rf.JSONSchema.Schema != nil {
if err := json.ValidateSchema(rf.JSONSchema.Schema); err != nil {
return fmt.Errorf("invalid JSON Schema in response_format: %w", err)
}
}
options.ResponseFormat = rf
} else if rfMap, ok := v.(map[string]interface{}); ok {
// Handle legacy map[string]interface{} format
// Try to parse into ResponseFormat struct
rf := &context.ResponseFormat{}
// Parse type
if typeStr, ok := rfMap["type"].(string); ok {
rf.Type = context.ResponseFormatType(typeStr)
}
// Parse json_schema if present
if jsonSchemaMap, ok := rfMap["json_schema"].(map[string]interface{}); ok {
jsonSchema := &context.JSONSchema{}
if name, ok := jsonSchemaMap["name"].(string); ok {
jsonSchema.Name = name
}
if desc, ok := jsonSchemaMap["description"].(string); ok {
jsonSchema.Description = desc
}
if schema, ok := jsonSchemaMap["schema"]; ok {
// Validate schema format - reject if invalid
if err := json.ValidateSchema(schema); err != nil {
return fmt.Errorf("invalid JSON Schema in response_format: %w", err)
}
jsonSchema.Schema = schema
}
if strict, ok := jsonSchemaMap["strict"].(bool); ok {
jsonSchema.Strict = &strict
}
rf.JSONSchema = jsonSchema
}
options.ResponseFormat = rf
}
}
// Seed
if v, ok := ast.Options["seed"].(float64); ok {
intVal := int(v)
options.Seed = &intVal
} else if v, ok := ast.Options["seed"].(int); ok {
options.Seed = &v
}
// Tools
if v, ok := ast.Options["tools"].([]interface{}); ok {
tools := make([]map[string]interface{}, 0, len(v))
for _, tool := range v {
if toolMap, ok := tool.(map[string]interface{}); ok {
tools = append(tools, toolMap)
}
}
if len(tools) > 0 {
options.Tools = tools
}
}
// ToolChoice
if v, ok := ast.Options["tool_choice"]; ok {
options.ToolChoice = v
}
// Stream
if v, ok := ast.Options["stream"].(bool); ok {
options.Stream = &v
}
return nil
}
// applyContextOptions applies options from ctx to CompletionOptions
// ctx provides Route and Metadata for CUI context
func (ast *Assistant) applyContextOptions(options *context.CompletionOptions, ctx *context.Context) {
// Set Route and Metadata from ctx
options.Route = ctx.Route
options.Metadata = ctx.Metadata
// Set Uses configurations (assistant.Uses has priority over global settings)
// These can be overridden by createResponse
options.Uses = ast.getUses()
}
// applyCreateResponseOptions applies options from createResponse to CompletionOptions
// createResponse takes highest priority and overrides any previous settings
func (ast *Assistant) applyCreateResponseOptions(options *context.CompletionOptions, createResponse *context.HookCreateResponse) {
// Audio configuration
if createResponse.Audio != nil {
options.Audio = createResponse.Audio
}
// Temperature
if createResponse.Temperature != nil {
options.Temperature = createResponse.Temperature
}
// MaxTokens
if createResponse.MaxTokens != nil {
options.MaxTokens = createResponse.MaxTokens
}
// MaxCompletionTokens
if createResponse.MaxCompletionTokens != nil {
options.MaxCompletionTokens = createResponse.MaxCompletionTokens
}
// Route
if createResponse.Route != "" {
options.Route = createResponse.Route
}
// Metadata (merge with existing)
if createResponse.Metadata != nil {
if options.Metadata == nil {
options.Metadata = createResponse.Metadata
} else {
// Merge: createResponse.Metadata overrides existing
for key, value := range createResponse.Metadata {
options.Metadata[key] = value
}
}
}
// Uses configuration (merge with existing)
// createResponse.Uses has highest priority and overrides existing Uses
if createResponse.Uses != nil {
if options.Uses == nil {
options.Uses = createResponse.Uses
} else {
// Merge: createResponse.Uses overrides existing (only non-empty fields)
if createResponse.Uses.Vision != "" {
options.Uses.Vision = createResponse.Uses.Vision
}
if createResponse.Uses.Audio != "" {
options.Uses.Audio = createResponse.Uses.Audio
}
if createResponse.Uses.Search != "" {
options.Uses.Search = createResponse.Uses.Search
}
if createResponse.Uses.Fetch != "" {
options.Uses.Fetch = createResponse.Uses.Fetch
}
}
}
// ForceUses configuration
// If hook specifies ForceUses, it takes priority
if createResponse.ForceUses != nil {
options.ForceUses = *createResponse.ForceUses
}
}
// getUses get the Uses configuration with priority: assistant.Uses > global settings
// Note: createResponse.Uses (applied in applyCreateResponseOptions) has even higher priority
// getUses returns the Uses config for this assistant
// Note: The config is already merged with global config during loading (loadMap)
func (ast *Assistant) getUses() *context.Uses {
return ast.Uses
}
// applyMCPTools adds MCP tools to completion options and returns samples prompt
// Returns (samplesPrompt, error)
func (ast *Assistant) applyMCPTools(ctx *context.Context, options *context.CompletionOptions, createResponse *context.HookCreateResponse) (string, error) {
// Priority 1: Check if hook provides MCP servers
if createResponse != nil && len(createResponse.MCPServers) > 0 {
return ast.buildAndApplyMCPTools(ctx, options, createResponse)
}
// Priority 2: Check if assistant has MCP config
if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
return ast.buildAndApplyMCPTools(ctx, options, nil)
}
// No MCP config
return "", nil
}
// buildAndApplyMCPTools builds MCP tools and applies them to options
func (ast *Assistant) buildAndApplyMCPTools(ctx *context.Context, options *context.CompletionOptions, createResponse *context.HookCreateResponse) (string, error) {
// Build MCP tools and get samples prompt
mcpTools, samplesPrompt, err := ast.buildMCPTools(ctx, createResponse)
if err != nil {
return "", fmt.Errorf("failed to build MCP tools: %w", err)
}
// Convert mcpTools to map format for CompletionOptions.Tools
if len(mcpTools) > 0 {
toolMaps := make([]map[string]interface{}, len(mcpTools))
for i, tool := range mcpTools {
toolMaps[i] = map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": tool.Name,
"description": tool.Description,
"parameters": tool.Parameters,
},
}
}
// Add MCP tools to existing tools (append to preserve existing tools)
if options.Tools == nil {
options.Tools = toolMaps
} else {
options.Tools = append(options.Tools, toolMaps...)
}
}
return samplesPrompt, nil
}

View file

@ -1,153 +0,0 @@
package assistant
import (
"fmt"
"github.com/yaoapp/yao/agent/content"
"github.com/yaoapp/yao/agent/content/text"
contentTypes "github.com/yaoapp/yao/agent/content/types"
"github.com/yaoapp/yao/agent/context"
)
// BuildContent processes messages through Vision function to convert extended content types
// (file, data) to standard LLM-compatible types (text, image_url, input_audio)
//
// This should be called after BuildRequest and before executing LLM call
func (ast *Assistant) BuildContent(ctx *context.Context, messages []context.Message, options *context.CompletionOptions, opts *context.Options) ([]context.Message, error) {
// Skip complex content parsing if requested (for internal calls like needsearch)
// Still convert file attachments to raw text
if opts != nil && opts.Skip != nil && opts.Skip.ContentParsing {
return convertFilesToText(ctx, messages), nil
}
// Set AssistantID in context for file info tracking in Space
// This ensures hooks can access file information using the correct namespace
if ctx.AssistantID == "" {
ctx.AssistantID = ast.ID
}
// Get connector and capabilities
connector, capabilities, err := ast.GetConnector(ctx, opts)
if err != nil {
return nil, fmt.Errorf("failed to get connector: %w", err)
}
// Build parse options
parseOptions := &contentTypes.Options{
Capabilities: capabilities,
CompletionOptions: options,
Connector: connector,
StreamOptions: options.StreamOptions,
}
contentMessages, referenceContext, err := content.ParseUserInput(ctx, messages, parseOptions)
if err != nil {
return nil, fmt.Errorf("failed to parse content: %w", err)
}
// Inject reference context into messages
if referenceContext != nil {
contentMessages = ast.injectSearchContext(contentMessages, referenceContext)
}
return contentMessages, nil
}
// convertFilesToText converts file attachments in messages to raw text
// Used when SkipContentParsing is enabled - simple text extraction without vision/PDF processing
func convertFilesToText(ctx *context.Context, messages []context.Message) []context.Message {
result := make([]context.Message, 0, len(messages))
textHandler := text.New(nil)
for _, msg := range messages {
// Only process user messages
if msg.Role != context.RoleUser {
result = append(result, msg)
continue
}
// Handle content parts
parts, ok := msg.Content.([]context.ContentPart)
if !ok {
// Try []interface{} (from history/JSON)
if iparts, ok := msg.Content.([]interface{}); ok {
parts = convertInterfaceToParts(iparts)
}
}
if len(parts) == 0 {
result = append(result, msg)
continue
}
// Convert file parts to text
newParts := make([]context.ContentPart, 0, len(parts))
for _, part := range parts {
switch part.Type {
case context.ContentFile:
// Convert file to raw text
if part.File != nil && part.File.URL != "" {
textPart, _, err := textHandler.ParseRaw(ctx, part)
if err == nil {
newParts = append(newParts, textPart)
continue
}
}
newParts = append(newParts, part)
case context.ContentImageURL:
// Skip images - cannot convert to text without vision
continue
default:
newParts = append(newParts, part)
}
}
newMsg := msg
newMsg.Content = newParts
result = append(result, newMsg)
}
return result
}
// convertInterfaceToParts converts []interface{} to []ContentPart for file extraction
func convertInterfaceToParts(items []interface{}) []context.ContentPart {
parts := make([]context.ContentPart, 0, len(items))
for _, item := range items {
m, ok := item.(map[string]interface{})
if !ok {
continue
}
typeStr, _ := m["type"].(string)
part := context.ContentPart{
Type: context.ContentPartType(typeStr),
}
switch typeStr {
case "text":
if t, ok := m["text"].(string); ok {
part.Text = t
}
case "file":
if fileData, ok := m["file"].(map[string]interface{}); ok {
part.File = &context.FileAttachment{}
if url, ok := fileData["url"].(string); ok {
part.File.URL = url
}
if filename, ok := fileData["filename"].(string); ok {
part.File.Filename = filename
}
}
case "image_url":
part.Type = context.ContentImageURL
default:
continue
}
parts = append(parts, part)
}
return parts
}

View file

@ -1,304 +0,0 @@
package assistant_test
import (
"testing"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
)
// TestBuildRequest_MCP tests MCP tool integration in BuildRequest
func TestBuildRequest_MCP(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.mcptest")
if err != nil {
t.Fatalf("Failed to get tests.mcptest assistant: %s", err.Error())
}
ctx := newTestContext("chat-test-mcp", "tests.mcptest")
t.Run("MCPToolsLoaded", func(t *testing.T) {
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test mcp tools"}}
// Build LLM request
_, options, err := agent.BuildRequest(ctx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify that tools are loaded
if options.Tools == nil {
t.Fatal("Expected tools to be loaded, got nil")
}
if len(options.Tools) == 0 {
t.Fatal("Expected at least some MCP tools, got empty list")
}
// Count MCP tools (should be filtered to only ping and echo)
mcpToolCount := 0
var toolNames []string
for _, toolMap := range options.Tools {
fn, ok := toolMap["function"].(map[string]interface{})
if !ok {
continue
}
name, ok := fn["name"].(string)
if ok {
toolNames = append(toolNames, name)
mcpToolCount++
}
}
t.Logf("Found %d MCP tools: %v", mcpToolCount, toolNames)
// Verify tool count (should be exactly 2: ping and echo)
if mcpToolCount != 2 {
t.Errorf("Expected 2 MCP tools (ping, echo), got %d: %v", mcpToolCount, toolNames)
}
// Verify specific tools exist
hasEchoPing := false
hasEchoEcho := false
for _, name := range toolNames {
if name == "echo__ping" {
hasEchoPing = true
}
if name == "echo__echo" {
hasEchoEcho = true
}
}
if !hasEchoPing {
t.Error("Expected 'echo__ping' tool to be present")
}
if !hasEchoEcho {
t.Error("Expected 'echo__echo' tool to be present")
}
// Verify that 'status' tool is NOT included (filtered out)
for _, name := range toolNames {
if name == "echo__status" {
t.Error("Tool 'echo__status' should be filtered out but was found")
}
}
t.Log("✓ MCP tools loaded and filtered correctly")
})
t.Run("MCPSamplesPrompt", func(t *testing.T) {
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test mcp samples"}}
// Build LLM request
finalMessages, _, err := agent.BuildRequest(ctx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Check if messages contain MCP samples prompt
// The samples prompt should be added as a system message
hasMCPSamples := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
if content, ok := msg.Content.(string); ok {
if len(content) > 50 &&
(contains(content, "MCP Tool Usage Examples") ||
contains(content, "echo.ping") ||
contains(content, "echo.echo")) {
hasMCPSamples = true
t.Logf("Found MCP samples prompt (length: %d chars)", len(content))
break
}
}
}
}
// Note: samples may not exist for echo tools, so this is informational
if hasMCPSamples {
t.Log("✓ MCP samples prompt included in messages")
} else {
t.Log(" No MCP samples prompt found (may not have sample files)")
}
})
t.Run("MCPToolNameFormat", func(t *testing.T) {
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test tool format"}}
// Build LLM request
_, options, err := agent.BuildRequest(ctx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify tool name format: server_id.tool_name
for _, toolMap := range options.Tools {
fn, ok := toolMap["function"].(map[string]interface{})
if !ok {
continue
}
name, ok := fn["name"].(string)
if ok {
// Parse tool name
serverID, toolName, ok := assistant.ParseMCPToolName(name)
if !ok {
t.Errorf("Tool name '%s' is not in correct format (server_id.tool_name)", name)
continue
}
// Verify server ID
if serverID != "echo" {
t.Errorf("Expected server_id 'echo', got '%s' for tool '%s'", serverID, name)
}
// Verify tool name is either ping or echo
if toolName != "ping" && toolName != "echo" {
t.Errorf("Expected tool name 'ping' or 'echo', got '%s'", toolName)
}
t.Logf("✓ Tool name format correct: %s → (%s, %s)", name, serverID, toolName)
}
}
})
t.Run("MCPToolSchema", func(t *testing.T) {
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test tool schema"}}
// Build LLM request
_, options, err := agent.BuildRequest(ctx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify tool schema structure
for _, toolMap := range options.Tools {
// Verify type field
if toolType, ok := toolMap["type"].(string); !ok || toolType != "function" {
t.Errorf("Expected tool type 'function', got: %v", toolMap["type"])
}
// Verify function field exists
fn, ok := toolMap["function"].(map[string]interface{})
if !ok {
t.Error("Tool missing 'function' field or wrong type")
continue
}
// Verify required fields
if _, hasName := fn["name"]; !hasName {
t.Error("Tool function missing 'name' field")
}
if _, hasDesc := fn["description"]; !hasDesc {
t.Error("Tool function missing 'description' field")
}
if _, hasParams := fn["parameters"]; !hasParams {
t.Error("Tool function missing 'parameters' field")
}
t.Logf("✓ Tool schema valid: %v", fn["name"])
}
})
t.Run("MCPHookOverride", func(t *testing.T) {
// Test that hook can override MCP servers
// Use tests.mcptest-hook which has a create hook that returns only ["ping"]
hookAgent, err := assistant.Get("tests.mcptest-hook")
if err != nil {
t.Fatalf("Failed to get tests.mcptest-hook assistant: %s", err.Error())
}
hookCtx := newTestContext("chat-test-mcp-hook", "tests.mcptest-hook")
inputMessages := []context.Message{{Role: context.RoleUser, Content: "test hook override"}}
// Call create hook to get createResponse
var createResponse *context.HookCreateResponse
if hookAgent.HookScript != nil {
createResponse, _, err = hookAgent.HookScript.Create(hookCtx, inputMessages, &context.Options{})
if err != nil {
t.Fatalf("Failed to call create hook: %s", err.Error())
}
t.Logf("Create hook response: %+v", createResponse)
if createResponse != nil && len(createResponse.MCPServers) > 0 {
t.Logf("Hook MCP servers: %+v", createResponse.MCPServers)
}
} else {
t.Fatal("Expected hookAgent to have Script/hook configured")
}
// Build LLM request with create hook response
_, options, err := hookAgent.BuildRequest(hookCtx, inputMessages, createResponse)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify that tools are loaded
if options.Tools == nil {
t.Fatal("Expected tools to be loaded, got nil")
}
// Count MCP tools
mcpToolCount := 0
var toolNames []string
for _, toolMap := range options.Tools {
fn, ok := toolMap["function"].(map[string]interface{})
if !ok {
continue
}
name, ok := fn["name"].(string)
if ok {
toolNames = append(toolNames, name)
mcpToolCount++
}
}
t.Logf("Found %d MCP tools after hook override: %v", mcpToolCount, toolNames)
// Verify tool count (hook should override to only 1: ping)
if mcpToolCount != 1 {
t.Errorf("Expected 1 MCP tool (ping only), got %d: %v", mcpToolCount, toolNames)
}
// Verify only ping tool exists
hasEchoPing := false
hasEchoEcho := false
for _, name := range toolNames {
if name == "echo__ping" {
hasEchoPing = true
}
if name == "echo__echo" {
hasEchoEcho = true
}
}
if !hasEchoPing {
t.Error("Expected 'echo__ping' tool to be present")
}
if hasEchoEcho {
t.Error("Tool 'echo__echo' should be filtered out by hook override but was found")
}
t.Log("✓ Hook successfully overrode MCP servers configuration")
})
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return len(s) >= len(substr) &&
(s == substr ||
len(s) > len(substr) &&
(s[:len(substr)] == substr ||
s[len(s)-len(substr):] == substr ||
findSubstring(s, substr)))
}
func findSubstring(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}

View file

@ -1,835 +0,0 @@
package assistant_test
import (
stdContext "context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// containsString is a helper to check if a content (string or interface{}) contains a substring
func containsString(content interface{}, substr string) bool {
switch v := content.(type) {
case string:
return strings.Contains(v, substr)
default:
return false
}
}
// newPromptTestContext creates a context suitable for prompt testing with Create Hook
func newPromptTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
ClientID: "test-client-id",
UserID: "test-user-123",
TeamID: "test-team-456",
TenantID: "test-tenant-789",
SessionID: "test-session-id",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "TestAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Metadata = make(map[string]interface{})
return ctx
}
// newMinimalTestContext creates a minimal context for testing
// Use this when you only need specific fields set
func newMinimalTestContext() *context.Context {
return context.New(stdContext.Background(), nil, "test-chat")
}
func TestBuildSystemPromptsIntegration(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("AssistantWithLocale", func(t *testing.T) {
// Load an assistant with locales
ast, err := assistant.Get("tests.fullfields")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Locale = "zh-cn"
ctx.Authorized = &types.AuthorizedInfo{
UserID: "test-user-123",
TeamID: "test-team-456",
}
ctx.Metadata = map[string]interface{}{
"CUSTOM_VAR": "custom-value",
"INT_VAR": 42,
"BOOL_VAR": true,
}
// Build request to test the full flow
messages := []context.Message{
{Role: context.RoleUser, Content: "Hello"},
}
finalMessages, options, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
require.NotNil(t, options)
// Should have system prompts prepended
assert.Greater(t, len(finalMessages), 1)
// First messages should be system prompts
hasSystemPrompt := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
hasSystemPrompt = true
break
}
}
assert.True(t, hasSystemPrompt, "Should have system prompts")
})
t.Run("DisableGlobalPrompts", func(t *testing.T) {
// Load fullfields assistant which has disable_global_prompts: true
ast, err := assistant.Get("tests.fullfields")
require.NoError(t, err)
require.True(t, ast.DisableGlobalPrompts)
ctx := newMinimalTestContext()
ctx.Locale = "en-us"
messages := []context.Message{
{Role: context.RoleUser, Content: "Hello"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Should still have assistant prompts
hasSystemPrompt := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
hasSystemPrompt = true
break
}
}
assert.True(t, hasSystemPrompt, "Should have assistant prompts even with global disabled")
})
t.Run("MetadataTypeConversion", func(t *testing.T) {
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Metadata = map[string]interface{}{
"STRING_VAL": "hello",
"INT_VAL": 123,
"INT64_VAL": int64(456),
"FLOAT_VAL": 3.14,
"BOOL_TRUE": true,
"BOOL_FALSE": false,
"UINT_VAL": uint(789),
"NIL_VAL": nil,
"EMPTY_VAL": "",
"ZERO_INT": 0,
"ZERO_FLOAT": 0.0,
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test metadata"},
}
// This should not panic
_, _, err = ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
})
t.Run("AuthorizedInfoPrivacy", func(t *testing.T) {
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Authorized = &types.AuthorizedInfo{
UserID: "user-123",
Subject: "user@example.com", // PII - should not be exposed
TeamID: "team-456",
TenantID: "tenant-789",
}
ctx.Client = context.Client{
Type: "web",
IP: "192.168.1.1", // Should not be exposed
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test privacy"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Check that sensitive info is not in any system prompts
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
assert.NotContains(t, msg.Content, "user@example.com", "Subject should not be in prompts")
assert.NotContains(t, msg.Content, "192.168.1.1", "IP should not be in prompts")
}
}
})
t.Run("ContextVariablesInPrompts", func(t *testing.T) {
// Set up global prompts with variables
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "User ID: $CTX.USER_ID, Team: $CTX.TEAM_ID, Custom: $CTX.MY_VAR"},
})
defer assistant.SetGlobalPrompts(nil)
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Authorized = &types.AuthorizedInfo{
UserID: "user-abc",
TeamID: "team-xyz",
}
ctx.Metadata = map[string]interface{}{
"MY_VAR": "my-value",
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test variables"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Find the global prompt and verify variables are replaced
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem && !found {
if assert.Contains(t, msg.Content, "User ID: user-abc") {
found = true
assert.Contains(t, msg.Content, "Team: team-xyz")
assert.Contains(t, msg.Content, "Custom: my-value")
}
}
}
assert.True(t, found, "Should find global prompt with replaced variables")
})
t.Run("SystemVariablesReplacement", func(t *testing.T) {
// Set up global prompts with $SYS.* variables
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "Time: $SYS.TIME, Date: $SYS.DATE, Datetime: $SYS.DATETIME, Weekday: $SYS.WEEKDAY"},
})
defer assistant.SetGlobalPrompts(nil)
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
ctx := newMinimalTestContext()
messages := []context.Message{
{Role: context.RoleUser, Content: "Test system variables"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Find the global prompt and verify $SYS.* variables are replaced
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
// Should NOT contain $SYS. prefix (variables should be replaced)
if !assert.NotContains(t, msg.Content, "$SYS.TIME") {
continue
}
if !assert.NotContains(t, msg.Content, "$SYS.DATE") {
continue
}
if !assert.NotContains(t, msg.Content, "$SYS.DATETIME") {
continue
}
if !assert.NotContains(t, msg.Content, "$SYS.WEEKDAY") {
continue
}
// Should contain "Time:", "Date:", etc. with actual values
assert.Contains(t, msg.Content, "Time:")
assert.Contains(t, msg.Content, "Date:")
assert.Contains(t, msg.Content, "Datetime:")
assert.Contains(t, msg.Content, "Weekday:")
found = true
break
}
}
assert.True(t, found, "Should find global prompt with replaced $SYS.* variables")
})
t.Run("EnvVariablesReplacement", func(t *testing.T) {
// Set test environment variable
t.Setenv("TEST_PROMPT_VAR", "env-test-value")
// Set up global prompts with $ENV.* variables
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "Env Value: $ENV.TEST_PROMPT_VAR, Not Exist: $ENV.NOT_EXIST_VAR_XYZ"},
})
defer assistant.SetGlobalPrompts(nil)
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
ctx := newMinimalTestContext()
messages := []context.Message{
{Role: context.RoleUser, Content: "Test env variables"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Find the global prompt and verify $ENV.* variables are replaced
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
// Should NOT contain $ENV. prefix for existing vars
if !assert.NotContains(t, msg.Content, "$ENV.TEST_PROMPT_VAR") {
continue
}
// Should contain the actual env value
assert.Contains(t, msg.Content, "Env Value: env-test-value")
// Non-existent env var should be replaced with empty string
assert.Contains(t, msg.Content, "Not Exist: ")
assert.NotContains(t, msg.Content, "$ENV.NOT_EXIST_VAR_XYZ")
found = true
break
}
}
assert.True(t, found, "Should find global prompt with replaced $ENV.* variables")
})
t.Run("AllVariableTypesReplacement", func(t *testing.T) {
// Set test environment variable
t.Setenv("TEST_APP_NAME", "MyTestApp")
// Set up global prompts with all variable types
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: `System Info:
- Time: $SYS.TIME
- Date: $SYS.DATE
- App: $ENV.TEST_APP_NAME
- User: $CTX.USER_ID
- Custom: $CTX.CUSTOM_KEY
- Assistant: $CTX.ASSISTANT_NAME`},
})
defer assistant.SetGlobalPrompts(nil)
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Authorized = &types.AuthorizedInfo{
UserID: "all-vars-user",
}
ctx.Metadata = map[string]interface{}{
"CUSTOM_KEY": "custom-value-123",
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test all variables"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Find the global prompt and verify ALL variable types are replaced
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem && !found {
content := msg.Content
// Check $SYS.* replaced
if assert.NotContains(t, content, "$SYS.TIME") &&
assert.NotContains(t, content, "$SYS.DATE") {
// Check $ENV.* replaced
assert.NotContains(t, content, "$ENV.TEST_APP_NAME")
assert.Contains(t, content, "App: MyTestApp")
// Check $CTX.* replaced
assert.NotContains(t, content, "$CTX.USER_ID")
assert.Contains(t, content, "User: all-vars-user")
assert.NotContains(t, content, "$CTX.CUSTOM_KEY")
assert.Contains(t, content, "Custom: custom-value-123")
// Check assistant name from $CTX.ASSISTANT_NAME
assert.NotContains(t, content, "$CTX.ASSISTANT_NAME")
found = true
}
}
}
assert.True(t, found, "Should find global prompt with all variable types replaced")
})
t.Run("PromptPresetFromHook", func(t *testing.T) {
// Load fullfields assistant which has prompt_presets
ast, err := assistant.Get("tests.fullfields")
require.NoError(t, err)
require.NotNil(t, ast.PromptPresets)
require.Contains(t, ast.PromptPresets, "chat.friendly")
ctx := newMinimalTestContext()
messages := []context.Message{
{Role: context.RoleUser, Content: "Test preset from hook"},
}
// Hook returns prompt_preset
createResponse := &context.HookCreateResponse{
PromptPreset: "chat.friendly",
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should have system prompts from the preset
hasSystemPrompt := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
hasSystemPrompt = true
// Verify it's from the friendly preset (check content)
assert.Contains(t, msg.Content, "friendly", "Should use friendly preset prompts")
break
}
}
assert.True(t, hasSystemPrompt, "Should have system prompts from preset")
})
t.Run("PromptPresetFromMetadata", func(t *testing.T) {
// Load fullfields assistant which has prompt_presets
ast, err := assistant.Get("tests.fullfields")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Metadata = map[string]interface{}{
"__prompt_preset": "chat.professional",
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test preset from metadata"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Should have system prompts from the preset
hasSystemPrompt := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
hasSystemPrompt = true
// Verify it's from the professional preset
assert.Contains(t, msg.Content, "professional", "Should use professional preset prompts")
break
}
}
assert.True(t, hasSystemPrompt, "Should have system prompts from preset")
})
t.Run("PromptPresetHookOverridesMetadata", func(t *testing.T) {
// Load fullfields assistant
ast, err := assistant.Get("tests.fullfields")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Metadata = map[string]interface{}{
"__prompt_preset": "chat.professional", // Lower priority
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test hook overrides metadata"},
}
// Hook returns different preset (higher priority)
createResponse := &context.HookCreateResponse{
PromptPreset: "chat.friendly",
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should use hook's preset, not metadata's
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
assert.Contains(t, msg.Content, "friendly", "Hook preset should override metadata preset")
break
}
}
})
t.Run("PromptPresetNotFound", func(t *testing.T) {
// Load fullfields assistant
ast, err := assistant.Get("tests.fullfields")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Metadata = map[string]interface{}{
"__prompt_preset": "non.existent.preset",
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test non-existent preset"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Should fallback to default prompts (not crash)
hasSystemPrompt := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
hasSystemPrompt = true
break
}
}
assert.True(t, hasSystemPrompt, "Should fallback to default prompts when preset not found")
})
t.Run("DisableGlobalPromptsFromHook", func(t *testing.T) {
// Set global prompts
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "GLOBAL_PROMPT_MARKER"},
})
defer assistant.SetGlobalPrompts(nil)
// Load an assistant that does NOT disable global prompts
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
require.False(t, ast.DisableGlobalPrompts)
ctx := newMinimalTestContext()
messages := []context.Message{
{Role: context.RoleUser, Content: "Test disable from hook"},
}
// Hook disables global prompts
disableTrue := true
createResponse := &context.HookCreateResponse{
DisableGlobalPrompts: &disableTrue,
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should NOT have global prompt
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
assert.NotContains(t, msg.Content, "GLOBAL_PROMPT_MARKER", "Global prompts should be disabled by hook")
}
}
})
t.Run("DisableGlobalPromptsFromMetadata", func(t *testing.T) {
// Set global prompts
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "GLOBAL_PROMPT_MARKER_2"},
})
defer assistant.SetGlobalPrompts(nil)
// Load an assistant that does NOT disable global prompts
ast, err := assistant.Get("yaobots")
require.NoError(t, err)
ctx := newMinimalTestContext()
ctx.Metadata = map[string]interface{}{
"__disable_global_prompts": true,
}
messages := []context.Message{
{Role: context.RoleUser, Content: "Test disable from metadata"},
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Should NOT have global prompt
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
assert.NotContains(t, msg.Content, "GLOBAL_PROMPT_MARKER_2", "Global prompts should be disabled by metadata")
}
}
})
t.Run("EnableGlobalPromptsOverrideAssistant", func(t *testing.T) {
// Set global prompts
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "GLOBAL_ENABLED_MARKER"},
})
defer assistant.SetGlobalPrompts(nil)
// Load fullfields assistant which has disable_global_prompts: true
ast, err := assistant.Get("tests.fullfields")
require.NoError(t, err)
require.True(t, ast.DisableGlobalPrompts)
ctx := newMinimalTestContext()
messages := []context.Message{
{Role: context.RoleUser, Content: "Test enable override"},
}
// Hook enables global prompts (overrides assistant's disable)
disableFalse := false
createResponse := &context.HookCreateResponse{
DisableGlobalPrompts: &disableFalse,
}
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should have global prompt (hook enabled it)
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem && msg.Content == "GLOBAL_ENABLED_MARKER" {
found = true
break
}
}
assert.True(t, found, "Global prompts should be enabled by hook override")
})
}
// TestPromptPresetAssistant tests the tests.promptpreset assistant with Create Hook
func TestPromptPresetAssistant(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
t.Run("LoadPromptPresetAssistant", func(t *testing.T) {
ast, err := assistant.Get("tests.promptpreset")
require.NoError(t, err)
require.NotNil(t, ast)
assert.Equal(t, "tests.promptpreset", ast.ID)
assert.Equal(t, "Prompt Preset Test", ast.Name)
assert.False(t, ast.DisableGlobalPrompts)
// Should have prompt presets loaded
require.NotNil(t, ast.PromptPresets)
assert.Contains(t, ast.PromptPresets, "mode.friendly")
assert.Contains(t, ast.PromptPresets, "mode.professional")
// Should have script
assert.NotNil(t, ast.HookScript)
})
t.Run("CreateHookSelectsFriendlyPreset", func(t *testing.T) {
ast, err := assistant.Get("tests.promptpreset")
require.NoError(t, err)
ctx := newPromptTestContext("chat-friendly-test", "tests.promptpreset")
messages := []context.Message{
{Role: context.RoleUser, Content: "use friendly mode please"},
}
// Call Create hook
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
require.NoError(t, err)
require.NotNil(t, createResponse)
assert.Equal(t, "mode.friendly", createResponse.PromptPreset)
// Build request
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should have friendly preset marker in one of the system messages
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem && containsString(msg.Content, "FRIENDLY_PRESET_MARKER") {
found = true
break
}
}
assert.True(t, found, "Should use friendly preset from Create Hook")
})
t.Run("CreateHookSelectsProfessionalPreset", func(t *testing.T) {
ast, err := assistant.Get("tests.promptpreset")
require.NoError(t, err)
ctx := newPromptTestContext("chat-professional-test", "tests.promptpreset")
messages := []context.Message{
{Role: context.RoleUser, Content: "use professional tone"},
}
// Call Create hook
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
require.NoError(t, err)
require.NotNil(t, createResponse)
assert.Equal(t, "mode.professional", createResponse.PromptPreset)
// Build request
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should have professional preset marker in one of the system messages
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem && containsString(msg.Content, "PROFESSIONAL_PRESET_MARKER") {
found = true
break
}
}
assert.True(t, found, "Should use professional preset from Create Hook")
})
t.Run("CreateHookDisablesGlobalPrompts", func(t *testing.T) {
// Set global prompts
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "GLOBAL_MARKER_FOR_DISABLE_TEST"},
})
defer assistant.SetGlobalPrompts(nil)
ast, err := assistant.Get("tests.promptpreset")
require.NoError(t, err)
ctx := newPromptTestContext("chat-disable-global-test", "tests.promptpreset")
messages := []context.Message{
{Role: context.RoleUser, Content: "disable global prompts"},
}
// Call Create hook
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
require.NoError(t, err)
require.NotNil(t, createResponse)
require.NotNil(t, createResponse.DisableGlobalPrompts)
assert.True(t, *createResponse.DisableGlobalPrompts)
// Build request
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should NOT have global prompt
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
assert.NotContains(t, msg.Content, "GLOBAL_MARKER_FOR_DISABLE_TEST")
}
}
})
t.Run("CreateHookPresetAndDisableGlobal", func(t *testing.T) {
// Set global prompts
assistant.SetGlobalPrompts([]store.Prompt{
{Role: "system", Content: "GLOBAL_MARKER_COMBINED_TEST"},
})
defer assistant.SetGlobalPrompts(nil)
ast, err := assistant.Get("tests.promptpreset")
require.NoError(t, err)
ctx := newPromptTestContext("chat-combined-test", "tests.promptpreset")
messages := []context.Message{
{Role: context.RoleUser, Content: "friendly no global"},
}
// Call Create hook
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
require.NoError(t, err)
require.NotNil(t, createResponse)
assert.Equal(t, "mode.friendly", createResponse.PromptPreset)
require.NotNil(t, createResponse.DisableGlobalPrompts)
assert.True(t, *createResponse.DisableGlobalPrompts)
// Build request
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should have friendly preset but NOT global
hasFriendly := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem {
assert.NotContains(t, msg.Content, "GLOBAL_MARKER_COMBINED_TEST")
if containsString(msg.Content, "FRIENDLY_PRESET_MARKER") {
hasFriendly = true
}
}
}
assert.True(t, hasFriendly, "Should have friendly preset")
})
t.Run("CreateHookUnknownPresetFallback", func(t *testing.T) {
ast, err := assistant.Get("tests.promptpreset")
require.NoError(t, err)
ctx := newPromptTestContext("chat-unknown-preset-test", "tests.promptpreset")
messages := []context.Message{
{Role: context.RoleUser, Content: "unknown preset test"},
}
// Call Create hook
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
require.NoError(t, err)
require.NotNil(t, createResponse)
assert.Equal(t, "non.existent.preset", createResponse.PromptPreset)
// Build request - should not error, fallback to default
finalMessages, _, err := ast.BuildRequest(ctx, messages, createResponse)
require.NoError(t, err)
// Should fallback to default prompts
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem && containsString(msg.Content, "DEFAULT_PROMPT_MARKER") {
found = true
break
}
}
assert.True(t, found, "Should fallback to default prompts when preset not found")
})
t.Run("CreateHookReturnsNull", func(t *testing.T) {
ast, err := assistant.Get("tests.promptpreset")
require.NoError(t, err)
ctx := newPromptTestContext("chat-null-test", "tests.promptpreset")
messages := []context.Message{
{Role: context.RoleUser, Content: "just a normal message"},
}
// Call Create hook - should return nil
createResponse, _, err := ast.HookScript.Create(ctx, messages, &context.Options{})
require.NoError(t, err)
assert.Nil(t, createResponse)
// Build request with nil createResponse
finalMessages, _, err := ast.BuildRequest(ctx, messages, nil)
require.NoError(t, err)
// Should use default prompts
found := false
for _, msg := range finalMessages {
if msg.Role == context.RoleSystem && containsString(msg.Content, "DEFAULT_PROMPT_MARKER") {
found = true
break
}
}
assert.True(t, found, "Should use default prompts when hook returns null")
})
}

View file

@ -1,410 +0,0 @@
package assistant_test
import (
stdContext "context"
"testing"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newTestContext creates a Context for testing with commonly used fields pre-populated
func newTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
ClientID: "test-client-id",
UserID: "test-user-123",
TeamID: "test-team-456",
TenantID: "test-tenant-789",
SessionID: "test-session-id",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "TestAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = "/test/route"
ctx.Metadata = map[string]interface{}{
"test": "context_metadata",
}
return ctx
}
// TestBuildRequest tests the BuildRequest function
func TestBuildRequest(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.buildrequest")
if err != nil {
t.Fatalf("Failed to get tests.buildrequest assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("The tests.buildrequest assistant has no script")
}
ctx := newTestContext("chat-test-buildrequest", "tests.buildrequest")
// Test 1: No override from hook - should use ast.Options and ctx values
t.Run("NoOverride", func(t *testing.T) {
inputMessages := []context.Message{{Role: "user", Content: "no_override"}}
// Call Create hook
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
if err != nil {
t.Fatalf("Failed to call Create hook: %s", err.Error())
}
// Build LLM request
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify options - should use ast.Options values
if options.Temperature == nil {
t.Error("Expected temperature from ast.Options, got nil")
} else if *options.Temperature != 0.5 {
t.Errorf("Expected temperature 0.5 from ast.Options, got: %f", *options.Temperature)
}
if options.MaxTokens == nil {
t.Error("Expected max_tokens from ast.Options, got nil")
} else if *options.MaxTokens != 1000 {
t.Errorf("Expected max_tokens 1000 from ast.Options, got: %d", *options.MaxTokens)
}
if options.TopP == nil {
t.Error("Expected top_p from ast.Options, got nil")
} else if *options.TopP != 0.9 {
t.Errorf("Expected top_p 0.9 from ast.Options, got: %f", *options.TopP)
}
// Verify ctx values
if options.Route != "/test/route" {
t.Errorf("Expected route '/test/route' from ctx, got: %s", options.Route)
}
if options.Metadata == nil {
t.Error("Expected metadata from ctx, got nil")
} else if options.Metadata["test"] != "context_metadata" {
t.Errorf("Expected metadata from ctx, got: %v", options.Metadata)
}
t.Log("✓ No override: ast.Options and ctx values used correctly")
})
// Test 2: Override temperature - hook value should take priority
t.Run("OverrideTemperature", func(t *testing.T) {
inputMessages := []context.Message{{Role: "user", Content: "override_temperature"}}
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
if err != nil {
t.Fatalf("Failed to call Create hook: %s", err.Error())
}
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify temperature override
if options.Temperature == nil {
t.Error("Expected temperature, got nil")
} else if *options.Temperature != 0.9 {
t.Errorf("Expected temperature 0.9 from hook, got: %f", *options.Temperature)
}
// Other values should still come from ast.Options
if options.MaxTokens == nil {
t.Error("Expected max_tokens from ast.Options, got nil")
} else if *options.MaxTokens != 1000 {
t.Errorf("Expected max_tokens 1000 from ast.Options, got: %d", *options.MaxTokens)
}
t.Log("✓ Temperature override: hook value takes priority over ast.Options")
})
// Test 3: Override all - all hook values should take priority
t.Run("OverrideAll", func(t *testing.T) {
inputMessages := []context.Message{{Role: "user", Content: "override_all"}}
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
if err != nil {
t.Fatalf("Failed to call Create hook: %s", err.Error())
}
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify all overrides
if options.Temperature == nil || *options.Temperature != 0.8 {
t.Errorf("Expected temperature 0.8 from hook, got: %v", options.Temperature)
}
if options.MaxTokens == nil || *options.MaxTokens != 2000 {
t.Errorf("Expected max_tokens 2000 from hook, got: %v", options.MaxTokens)
}
if options.MaxCompletionTokens == nil || *options.MaxCompletionTokens != 1800 {
t.Errorf("Expected max_completion_tokens 1800 from hook, got: %v", options.MaxCompletionTokens)
}
if options.Audio == nil {
t.Error("Expected audio from hook, got nil")
} else {
if options.Audio.Voice != "alloy" {
t.Errorf("Expected voice 'alloy', got: %s", options.Audio.Voice)
}
if options.Audio.Format != "mp3" {
t.Errorf("Expected format 'mp3', got: %s", options.Audio.Format)
}
}
if options.Route != "/hook/route" {
t.Errorf("Expected route '/hook/route' from hook, got: %s", options.Route)
}
if options.Metadata == nil {
t.Error("Expected metadata from hook, got nil")
} else {
if options.Metadata["source"] != "hook" {
t.Errorf("Expected metadata['source'] = 'hook', got: %v", options.Metadata["source"])
}
}
t.Log("✓ Override all: all hook values take priority")
})
// Test 4: Override route and metadata - tests CUI context priority
t.Run("OverrideRouteMetadata", func(t *testing.T) {
inputMessages := []context.Message{{Role: "user", Content: "override_route_metadata"}}
createResponse, _, err := agent.HookScript.Create(ctx, inputMessages, &context.Options{})
if err != nil {
t.Fatalf("Failed to call Create hook: %s", err.Error())
}
_, options, err := agent.BuildRequest(ctx, inputMessages, createResponse)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify route override
if options.Route != "/custom/route" {
t.Errorf("Expected route '/custom/route' from hook, got: %s", options.Route)
}
// Verify metadata merge (ctx metadata should be merged with hook metadata)
if options.Metadata == nil {
t.Error("Expected metadata, got nil")
} else {
// Hook metadata should be present
if options.Metadata["custom"] != true {
t.Errorf("Expected metadata['custom'] = true from hook, got: %v", options.Metadata["custom"])
}
if options.Metadata["hook_data"] != "test" {
t.Errorf("Expected metadata['hook_data'] = 'test' from hook, got: %v", options.Metadata["hook_data"])
}
// Original ctx metadata should still be there (merged)
if options.Metadata["test"] != "context_metadata" {
t.Errorf("Expected original ctx metadata to be preserved, got: %v", options.Metadata)
}
}
// Other values should still come from ast.Options
if options.Temperature == nil || *options.Temperature != 0.5 {
t.Errorf("Expected temperature 0.5 from ast.Options, got: %v", options.Temperature)
}
t.Log("✓ Route and metadata override: hook values take priority, metadata merged")
})
// Test 5: Nil createResponse - should use ast.Options and ctx values
t.Run("NilCreateResponse", func(t *testing.T) {
// Create a fresh context for this test
freshCtx := newTestContext("chat-test-nil", "tests.buildrequest")
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
_, options, err := agent.BuildRequest(freshCtx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Should use ast.Options values
if options.Temperature == nil || *options.Temperature != 0.5 {
t.Errorf("Expected temperature 0.5 from ast.Options, got: %v", options.Temperature)
}
// Should use ctx values
if options.Route != "/test/route" {
t.Errorf("Expected route '/test/route' from ctx, got: %s", options.Route)
}
t.Log("✓ Nil createResponse: ast.Options and ctx values used")
})
// Test 6: ResponseFormat with *context.ResponseFormat
t.Run("ResponseFormatStruct", func(t *testing.T) {
freshCtx := newTestContext("chat-test-response-format", "tests.buildrequest")
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
// Create a test agent with response_format in Options
testAgent := *agent
strict := true
testAgent.Options = map[string]interface{}{
"temperature": 0.7,
"response_format": &context.ResponseFormat{
Type: context.ResponseFormatJSONSchema,
JSONSchema: &context.JSONSchema{
Name: "test_schema",
Description: "Test schema description",
Schema: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
},
},
},
Strict: &strict,
},
},
}
_, options, err := testAgent.BuildRequest(freshCtx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify ResponseFormat
if options.ResponseFormat == nil {
t.Fatal("Expected ResponseFormat, got nil")
}
if options.ResponseFormat.Type != context.ResponseFormatJSONSchema {
t.Errorf("Expected type 'json_schema', got: %s", options.ResponseFormat.Type)
}
if options.ResponseFormat.JSONSchema == nil {
t.Fatal("Expected JSONSchema, got nil")
}
if options.ResponseFormat.JSONSchema.Name != "test_schema" {
t.Errorf("Expected schema name 'test_schema', got: %s", options.ResponseFormat.JSONSchema.Name)
}
if options.ResponseFormat.JSONSchema.Description != "Test schema description" {
t.Errorf("Expected schema description 'Test schema description', got: %s", options.ResponseFormat.JSONSchema.Description)
}
if options.ResponseFormat.JSONSchema.Strict == nil || *options.ResponseFormat.JSONSchema.Strict != true {
t.Errorf("Expected strict = true, got: %v", options.ResponseFormat.JSONSchema.Strict)
}
t.Log("✓ ResponseFormat with *context.ResponseFormat struct works correctly")
})
// Test 7: ResponseFormat with legacy map[string]interface{}
t.Run("ResponseFormatLegacyMap", func(t *testing.T) {
freshCtx := newTestContext("chat-test-response-format-map", "tests.buildrequest")
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
// Create a test agent with legacy map format
testAgent := *agent
testAgent.Options = map[string]interface{}{
"temperature": 0.7,
"response_format": map[string]interface{}{
"type": "json_schema",
"json_schema": map[string]interface{}{
"name": "legacy_schema",
"description": "Legacy schema format",
"schema": map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"email": map[string]interface{}{
"type": "string",
},
},
},
"strict": true,
},
},
}
_, options, err := testAgent.BuildRequest(freshCtx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify ResponseFormat was converted from map
if options.ResponseFormat == nil {
t.Fatal("Expected ResponseFormat, got nil")
}
if options.ResponseFormat.Type != context.ResponseFormatJSONSchema {
t.Errorf("Expected type 'json_schema', got: %s", options.ResponseFormat.Type)
}
if options.ResponseFormat.JSONSchema == nil {
t.Fatal("Expected JSONSchema, got nil")
}
if options.ResponseFormat.JSONSchema.Name != "legacy_schema" {
t.Errorf("Expected schema name 'legacy_schema', got: %s", options.ResponseFormat.JSONSchema.Name)
}
if options.ResponseFormat.JSONSchema.Description != "Legacy schema format" {
t.Errorf("Expected schema description 'Legacy schema format', got: %s", options.ResponseFormat.JSONSchema.Description)
}
t.Log("✓ ResponseFormat with legacy map[string]interface{} format works correctly")
})
// Test 8: ResponseFormat with simple type (text or json_object)
t.Run("ResponseFormatSimpleType", func(t *testing.T) {
freshCtx := newTestContext("chat-test-response-format-simple", "tests.buildrequest")
inputMessages := []context.Message{{Role: "user", Content: "test message"}}
// Create a test agent with simple response_format
testAgent := *agent
testAgent.Options = map[string]interface{}{
"temperature": 0.7,
"response_format": map[string]interface{}{
"type": "json_object",
},
}
_, options, err := testAgent.BuildRequest(freshCtx, inputMessages, nil)
if err != nil {
t.Fatalf("Failed to build LLM request: %s", err.Error())
}
// Verify ResponseFormat
if options.ResponseFormat == nil {
t.Fatal("Expected ResponseFormat, got nil")
}
if options.ResponseFormat.Type != context.ResponseFormatJSON {
t.Errorf("Expected type 'json_object', got: %s", options.ResponseFormat.Type)
}
if options.ResponseFormat.JSONSchema != nil {
t.Errorf("Expected JSONSchema to be nil for simple type, got: %v", options.ResponseFormat.JSONSchema)
}
t.Log("✓ ResponseFormat with simple type (json_object) works correctly")
})
}

View file

@ -1,169 +0,0 @@
package assistant
import (
"container/list"
"sync"
)
// Cache represents a thread-safe LRU cache for Assistant objects
type Cache struct {
capacity int
mu sync.RWMutex
list *list.List
items map[string]*list.Element
}
// cacheItem represents an item in the cache
type cacheItem struct {
key string
value *Assistant
}
// NewCache creates a new LRU cache with the given capacity
func NewCache(capacity int) *Cache {
return &Cache{
capacity: capacity,
list: list.New(),
items: make(map[string]*list.Element),
}
}
// Get retrieves an Assistant from the cache by its ID
func (c *Cache) Get(id string) (*Assistant, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if element, exists := c.items[id]; exists {
c.list.MoveToFront(element)
return element.Value.(*cacheItem).value, true
}
return nil, false
}
// Put adds or updates an Assistant in the cache
func (c *Cache) Put(assistant *Assistant) {
if assistant == nil || assistant.ID == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
// If item exists, update it and move to front
if element, exists := c.items[assistant.ID]; exists {
c.list.MoveToFront(element)
element.Value.(*cacheItem).value = assistant
return
}
// If cache is at capacity, remove oldest item before adding new one
if c.list.Len() >= c.capacity {
c.removeOldest()
}
// Add new item
element := c.list.PushFront(&cacheItem{
key: assistant.ID,
value: assistant,
})
c.items[assistant.ID] = element
}
// Remove removes an Assistant from the cache
func (c *Cache) Remove(id string) {
c.mu.Lock()
defer c.mu.Unlock()
if element, exists := c.items[id]; exists {
item := element.Value.(*cacheItem)
// Unregister scripts before removing from cache
if item.value != nil && len(item.value.Scripts) > 0 {
item.value.UnregisterScripts()
}
c.list.Remove(element)
delete(c.items, id)
}
}
// Len returns the current number of items in the cache
func (c *Cache) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.list.Len()
}
// All returns all assistants in the cache
func (c *Cache) All() []*Assistant {
c.mu.RLock()
defer c.mu.RUnlock()
assistants := make([]*Assistant, 0, c.list.Len())
for element := c.list.Front(); element != nil; element = element.Next() {
item := element.Value.(*cacheItem)
assistants = append(assistants, item.value)
}
return assistants
}
// Clear removes all items from the cache
func (c *Cache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
// Unregister all scripts before clearing cache
for element := c.list.Front(); element != nil; element = element.Next() {
item := element.Value.(*cacheItem)
if item.value != nil && len(item.value.Scripts) > 0 {
item.value.UnregisterScripts()
}
}
c.list.Init()
c.items = make(map[string]*list.Element)
}
// ClearExcept removes items from the cache except those matching the keep function
// keep function returns true for items that should be preserved
func (c *Cache) ClearExcept(keep func(id string) bool) {
c.mu.Lock()
defer c.mu.Unlock()
// Collect items to remove
var toRemove []*list.Element
for element := c.list.Front(); element != nil; element = element.Next() {
item := element.Value.(*cacheItem)
if !keep(item.key) {
toRemove = append(toRemove, element)
}
}
// Remove collected items
for _, element := range toRemove {
item := element.Value.(*cacheItem)
// Unregister scripts before removing
if item.value != nil && len(item.value.Scripts) > 0 {
item.value.UnregisterScripts()
}
c.list.Remove(element)
delete(c.items, item.key)
}
}
// removeOldest removes the least recently used item from the cache
func (c *Cache) removeOldest() {
if element := c.list.Back(); element != nil {
item := element.Value.(*cacheItem)
// Unregister scripts before removing from cache
if item.value != nil && len(item.value.Scripts) > 0 {
item.value.UnregisterScripts()
}
c.list.Remove(element)
delete(c.items, item.key)
}
}

View file

@ -1,253 +0,0 @@
package assistant_test
import (
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/testutils"
)
func TestCacheBasic(t *testing.T) {
cache := assistant.NewCache(2)
// Test empty cache
assert.Equal(t, 0, cache.Len(), "Expected empty cache")
// Create test assistants
testutils.Prepare(t)
defer testutils.Clean(t)
ast1, err := assistant.Get("tests.mcpload")
assert.NoError(t, err)
ast2, err := assistant.Get("tests.create")
assert.NoError(t, err)
// Test adding items
cache.Put(ast1)
cache.Put(ast2)
assert.Equal(t, 2, cache.Len(), "Expected cache length 2")
// Test getting items
cached1, exists := cache.Get("tests.mcpload")
assert.True(t, exists, "Should find tests.mcpload")
assert.Equal(t, "tests.mcpload", cached1.ID)
cached2, exists := cache.Get("tests.create")
assert.True(t, exists, "Should find tests.create")
assert.Equal(t, "tests.create", cached2.ID)
}
func TestCacheLRU(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
cache := assistant.NewCache(2)
ast1, _ := assistant.Get("tests.mcpload")
ast2, _ := assistant.Get("tests.create")
ast3, _ := assistant.Get("tests.next")
// Add first two items
cache.Put(ast1)
cache.Put(ast2)
// Access ast1 to make it most recently used
cache.Get("tests.mcpload")
// Add third item, should evict ast2
cache.Put(ast3)
// Check ast2 was evicted
_, exists := cache.Get("tests.create")
assert.False(t, exists, "tests.create should have been evicted")
// Check ast1 and ast3 are still present
_, exists = cache.Get("tests.mcpload")
assert.True(t, exists, "tests.mcpload should still be in cache")
_, exists = cache.Get("tests.next")
assert.True(t, exists, "tests.next should be in cache")
}
func TestCacheRemove(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
cache := assistant.NewCache(2)
ast1, _ := assistant.Get("tests.mcpload")
cache.Put(ast1)
// Verify scripts are registered
_, exists := process.Handlers["agents.tests.mcpload.tools"]
assert.True(t, exists, "Handler should be registered before removal")
// Test remove existing item
cache.Remove("tests.mcpload")
assert.Equal(t, 0, cache.Len(), "Cache should be empty after removing item")
// Verify scripts are unregistered
_, exists = process.Handlers["agents.tests.mcpload.tools"]
assert.False(t, exists, "Handler should be unregistered after removal")
// Test remove non-existing item (should not panic)
cache.Remove("nonexistent")
assert.Equal(t, 0, cache.Len(), "Cache length should not change")
}
func TestCacheClear(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
cache := assistant.NewCache(3)
ast1, _ := assistant.Get("tests.mcpload")
ast2, _ := assistant.Get("tests.create")
ast3, _ := assistant.Get("tests.next")
cache.Put(ast1)
cache.Put(ast2)
cache.Put(ast3)
assert.Equal(t, 3, cache.Len(), "Cache should have 3 items")
// Verify scripts are registered
_, exists := process.Handlers["agents.tests.mcpload.tools"]
assert.True(t, exists, "Handler should be registered before clear")
// Clear cache
cache.Clear()
assert.Equal(t, 0, cache.Len(), "Cache should be empty after clear")
// Verify all scripts are unregistered
_, exists = process.Handlers["agents.tests.mcpload.tools"]
assert.False(t, exists, "Handler should be unregistered after clear")
}
func TestCacheLRUEviction(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
cache := assistant.NewCache(2)
ast1, _ := assistant.Get("tests.mcpload")
ast2, _ := assistant.Get("tests.create")
ast3, _ := assistant.Get("tests.next")
cache.Put(ast1)
cache.Put(ast2)
// Verify both are registered
_, exists1 := process.Handlers["agents.tests.mcpload.tools"]
assert.True(t, exists1, "Handler 1 should be registered")
// Add third item to trigger LRU eviction of oldest (ast1)
cache.Put(ast3)
// Verify ast1's handler was unregistered due to eviction
_, exists := process.Handlers["agents.tests.mcpload.tools"]
assert.False(t, exists, "Handler should be unregistered after LRU eviction")
// Verify ast2 and ast3 are still in cache
_, exists = cache.Get("tests.create")
assert.True(t, exists, "tests.create should still be in cache")
_, exists = cache.Get("tests.next")
assert.True(t, exists, "tests.next should be in cache")
}
func TestCacheConcurrent(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
cache := assistant.NewCache(10)
var wg sync.WaitGroup
workers := 5
iterations := 20
// Load some assistants for concurrent testing
assistants := []string{
"tests.mcpload",
"tests.create",
"tests.next",
}
// Concurrent writes
for i := 0; i < workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
astID := assistants[j%len(assistants)]
ast, _ := assistant.Get(astID)
if ast != nil {
cache.Put(ast)
}
}
}(i)
}
// Concurrent reads
for i := 0; i < workers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
astID := assistants[j%len(assistants)]
cache.Get(astID)
}
}(i)
}
wg.Wait()
// Verify cache is in valid state
assert.True(t, cache.Len() >= 0, "Cache should have valid length")
assert.True(t, cache.Len() <= 10, "Cache should not exceed capacity")
}
func TestCacheNilInput(t *testing.T) {
cache := assistant.NewCache(2)
// Test putting nil assistant
cache.Put(nil)
assert.Equal(t, 0, cache.Len(), "Cache should not store nil assistant")
// Test putting assistant with empty ID
emptyAST := &assistant.Assistant{}
cache.Put(emptyAST)
assert.Equal(t, 0, cache.Len(), "Cache should not store assistant with empty ID")
}
func TestCacheAll(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
cache := assistant.NewCache(5)
ast1, _ := assistant.Get("tests.mcpload")
ast2, _ := assistant.Get("tests.create")
ast3, _ := assistant.Get("tests.next")
cache.Put(ast1)
cache.Put(ast2)
cache.Put(ast3)
all := cache.All()
assert.Equal(t, 3, len(all), "All() should return 3 assistants")
// Verify all expected assistants are present
ids := make(map[string]bool)
for _, ast := range all {
ids[ast.ID] = true
}
assert.True(t, ids["tests.mcpload"], "Should contain tests.mcpload")
assert.True(t, ids["tests.create"], "Should contain tests.create")
assert.True(t, ids["tests.next"], "Should contain tests.next")
}

View file

@ -1,400 +0,0 @@
package assistant
import (
"fmt"
"strings"
"time"
"github.com/google/uuid"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
storetypes "github.com/yaoapp/yao/agent/store/types"
)
// InitializeConversation prepares conversation context (synchronous)
// KB collection is now initialized when user logs in (see openapi/user/login.go)
func (ast *Assistant) InitializeConversation(ctx *agentcontext.Context, options ...*agentcontext.Options) error {
// Reserved for future conversation initialization logic
return nil
}
// InitializeConversationAsync prepares conversation context asynchronously
func (ast *Assistant) InitializeConversationAsync(ctx *agentcontext.Context, options ...*agentcontext.Options) {
go ast.InitializeConversation(ctx, options...)
}
// GetChatKBID returns the KB collection ID for a chat session
// Same team + user always returns the same ID (deterministic)
// Format: chat_{team}_{user} or chat_user_{user} if no team
func GetChatKBID(teamID, userID string) string {
// Sanitize IDs: replace invalid chars with underscores
cleanTeamID := sanitizeCollectionID(teamID)
cleanUserID := sanitizeCollectionID(userID)
if cleanTeamID != "" {
return fmt.Sprintf("chat_%s_%s", cleanTeamID, cleanUserID)
}
return fmt.Sprintf("chat_user_%s", cleanUserID)
}
// sanitizeCollectionID replaces invalid characters with underscores
// Collection IDs only allow: a-z, A-Z, 0-9, and underscore
func sanitizeCollectionID(id string) string {
if id == "" {
return ""
}
// Replace any character that is not alphanumeric or underscore with underscore
result := make([]byte, len(id))
for i := 0; i < len(id); i++ {
c := id[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' {
result[i] = c
} else {
result[i] = '_'
}
}
return string(result)
}
// mergeChatMetadata merges default metadata with chat context information
func mergeChatMetadata(defaultMetadata map[string]interface{}, ctx *agentcontext.Context) map[string]interface{} {
metadata := make(map[string]interface{})
// Copy default metadata
for k, v := range defaultMetadata {
metadata[k] = v
}
// Add chat-specific metadata (only for internal tracking, not displayed)
metadata["chat_id"] = ctx.ChatID
metadata["team_id"] = ctx.Authorized.TeamID
metadata["user_id"] = ctx.Authorized.UserID
// Get locale from context, default to zh-CN if not set
locale := ctx.Locale
if locale == "" {
locale = "zh-CN"
}
locale = strings.ToLower(locale)
// Use i18n for name and description (fixed, not showing user/team IDs)
if _, exists := metadata["name"]; !exists {
metadata["name"] = i18n.T(locale, "kb.chat.name")
}
if _, exists := metadata["description"]; !exists {
metadata["description"] = i18n.T(locale, "kb.chat.description")
}
return metadata
}
// =============================================================================
// Chat Buffer Integration
// =============================================================================
// InitBuffer initializes the chat buffer for the context
// Should be called at the start of Stream() for root stack only
func (ast *Assistant) InitBuffer(ctx *agentcontext.Context) {
// Only initialize for root stack
if ctx.Stack == nil || !ctx.Stack.IsRoot() {
return
}
// Skip if buffer already exists
if ctx.Buffer != nil {
return
}
// Skip if History is disabled in options
if ctx.Stack.Options != nil && ctx.Stack.Options.Skip != nil && ctx.Stack.Options.Skip.History {
ctx.Logger.Debug("Buffer skipped: Skip.History is true")
return
}
// Generate request ID if not set
requestID := ctx.RequestID()
if requestID == "" {
requestID = uuid.New().String()
}
// Get connector and mode from options
connector := ""
mode := ""
if ctx.Stack.Options != nil {
connector = ctx.Stack.Options.Connector
mode = ctx.Stack.Options.Mode
}
ctx.Buffer = agentcontext.NewChatBuffer(ctx.ChatID, requestID, ast.ID, connector, mode)
ctx.Logger.Debug("Buffer initialized: chatID=%s, requestID=%s, assistantID=%s", ctx.ChatID, requestID, ast.ID)
}
// BufferUserInput adds user input messages to the buffer
// Should be called after InitBuffer
func (ast *Assistant) BufferUserInput(ctx *agentcontext.Context, inputMessages []agentcontext.Message) {
if ctx.Buffer == nil {
return
}
// Only root stack should buffer user input
// Delegated agents share the same buffer but should not duplicate user input
if ctx.Stack != nil && !ctx.Stack.IsRoot() {
return
}
// Convert input messages to buffer format
for _, msg := range inputMessages {
// Extract content from message
var content interface{}
var name string
content = msg.Content
if msg.Name != nil {
name = *msg.Name
}
ctx.Buffer.AddUserInput(content, name)
}
}
// UpdateSpaceSnapshot updates the context memory snapshot in the buffer
// Only captures Context-level memory (request-scoped temporary data) for recovery
func (ast *Assistant) UpdateSpaceSnapshot(ctx *agentcontext.Context) {
if ctx.Buffer == nil || ctx.Memory == nil || ctx.Memory.Context == nil {
return
}
snapshot := ctx.Memory.Context.Snapshot()
ctx.Buffer.SetSpaceSnapshot(snapshot)
}
// BeginStep starts tracking an execution step
// Returns the step for further updates
func (ast *Assistant) BeginStep(ctx *agentcontext.Context, stepType string, input map[string]interface{}) *agentcontext.BufferedStep {
if ctx.Buffer == nil {
return nil
}
// Update space snapshot before beginning step
ast.UpdateSpaceSnapshot(ctx)
return ctx.Buffer.BeginStep(stepType, input, ctx.Stack)
}
// CompleteStep marks the current step as completed
func (ast *Assistant) CompleteStep(ctx *agentcontext.Context, output map[string]interface{}) {
if ctx.Buffer == nil {
return
}
ctx.Buffer.CompleteStep(output)
}
// FlushBuffer saves all buffered data to the database
// Should be called in defer block at the end of Stream()
func (ast *Assistant) FlushBuffer(ctx *agentcontext.Context, finalStatus string, err error) {
if ctx.Buffer == nil {
return
}
// Only flush for root stack
if ctx.Stack == nil || !ctx.Stack.IsRoot() {
return
}
// Get chat store
chatStore := GetChatStore()
if chatStore == nil {
ctx.Logger.Error("Chat store not available, cannot flush buffer")
return
}
// Mark current step as failed/interrupted if needed
if finalStatus != agentcontext.StepStatusCompleted && err != nil {
ctx.Buffer.FailCurrentStep(finalStatus, err)
}
// 1. Save all messages (user input + assistant responses)
messages := ast.convertBufferedMessages(ctx.Buffer.GetMessages())
if len(messages) > 0 {
if saveErr := chatStore.SaveMessages(ctx.ChatID, messages); saveErr != nil {
ctx.Logger.Error("Failed to save messages: %v", saveErr)
} else {
ctx.Logger.Debug("Saved %d messages for chat=%s", len(messages), ctx.ChatID)
}
}
// 2. Update chat last_message_at, last_connector, and last_mode
if len(messages) > 0 {
now := time.Now()
updates := map[string]interface{}{
"last_message_at": now,
}
// Also update last_connector if available
if connector := ctx.Buffer.Connector(); connector != "" {
updates["last_connector"] = connector
}
// Also update last_mode if available
if mode := ctx.Buffer.Mode(); mode != "" {
updates["last_mode"] = mode
}
if updateErr := chatStore.UpdateChat(ctx.ChatID, updates); updateErr != nil {
ctx.Logger.Debug("Failed to update chat: %v", updateErr)
}
}
// 3. Only save resume steps on error/interrupt (not on success)
if finalStatus != agentcontext.StepStatusCompleted {
steps := ast.convertBufferedSteps(ctx.Buffer.GetStepsForResume(finalStatus))
if len(steps) > 0 {
if saveErr := chatStore.SaveResume(steps); saveErr != nil {
ctx.Logger.Error("Failed to save resume steps: %v", saveErr)
} else {
ctx.Logger.Debug("Saved %d resume steps for chat=%s (status=%s)", len(steps), ctx.ChatID, finalStatus)
}
}
}
// 4. Close SafeWriter to flush remaining writes (root stack only)
// This ensures all pending SSE messages are sent before the response completes
ctx.CloseSafeWriter()
}
// convertBufferedMessages converts BufferedMessage slice to store Message slice
func (ast *Assistant) convertBufferedMessages(buffered []*agentcontext.BufferedMessage) []*storetypes.Message {
if len(buffered) == 0 {
return nil
}
messages := make([]*storetypes.Message, len(buffered))
for i, msg := range buffered {
messages[i] = &storetypes.Message{
MessageID: msg.MessageID,
ChatID: msg.ChatID,
RequestID: msg.RequestID,
Role: msg.Role,
Type: msg.Type,
Props: msg.Props,
BlockID: msg.BlockID,
ThreadID: msg.ThreadID,
AssistantID: msg.AssistantID,
Connector: msg.Connector,
Mode: msg.Mode,
Sequence: msg.Sequence,
Metadata: msg.Metadata,
CreatedAt: msg.CreatedAt,
UpdatedAt: msg.CreatedAt,
}
}
return messages
}
// convertBufferedSteps converts BufferedStep slice to store Resume slice
func (ast *Assistant) convertBufferedSteps(buffered []*agentcontext.BufferedStep) []*storetypes.Resume {
if len(buffered) == 0 {
return nil
}
steps := make([]*storetypes.Resume, len(buffered))
for i, step := range buffered {
steps[i] = &storetypes.Resume{
ResumeID: step.ResumeID,
ChatID: step.ChatID,
RequestID: step.RequestID,
AssistantID: step.AssistantID,
StackID: step.StackID,
StackParentID: step.StackParentID,
StackDepth: step.StackDepth,
Type: step.Type,
Status: step.Status,
Input: step.Input,
Output: step.Output,
SpaceSnapshot: step.SpaceSnapshot,
Error: step.Error,
Sequence: step.Sequence,
Metadata: step.Metadata,
CreatedAt: step.CreatedAt,
UpdatedAt: step.CreatedAt,
}
}
return steps
}
// EnsureChat ensures a chat session exists, creates if not
func (ast *Assistant) EnsureChat(ctx *agentcontext.Context) error {
if ctx.ChatID == "" {
return nil // No chat ID, skip
}
// Skip if history is disabled
if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.Skip != nil && ctx.Stack.Options.Skip.History {
return nil // Skip.History is true, don't create chat session
}
chatStore := GetChatStore()
if chatStore == nil {
return nil // No store, skip
}
// Check if chat exists
_, err := chatStore.GetChat(ctx.ChatID)
if err == nil {
return nil // Chat exists
}
// Create new chat with permission fields
chat := &storetypes.Chat{
ChatID: ctx.ChatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
Sort: 0,
Metadata: ctx.Metadata,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set last_connector from options (user selected connector)
if ctx.Stack != nil && ctx.Stack.Options != nil && ctx.Stack.Options.Connector != "" {
chat.LastConnector = ctx.Stack.Options.Connector
}
// Set permission fields from authorized info
if ctx.Authorized != nil {
chat.CreatedBy = ctx.Authorized.UserID
chat.UpdatedBy = ctx.Authorized.UserID
chat.TeamID = ctx.Authorized.TeamID
chat.TenantID = ctx.Authorized.TenantID
}
return chatStore.CreateChat(chat)
}
// GetChatStore returns the chat store instance
// Returns nil if storage is not configured
func GetChatStore() storetypes.ChatStore {
if storage == nil {
return nil
}
return storage
}
// GetStore returns the full store instance (implements both ChatStore and AssistantStore)
// Returns nil if storage is not configured
func GetStore() storetypes.Store {
if storage == nil {
return nil
}
return storage
}
// =============================================================================
// Deprecated methods (kept for compatibility)
// =============================================================================
func (ast *Assistant) saveChat(ctx *agentcontext.Context, input []agentcontext.Message, opts *agentcontext.Options) error {
_ = ctx
_ = input
_ = opts
return nil
}

View file

@ -1,995 +0,0 @@
package assistant_test
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
agentcontext "github.com/yaoapp/yao/agent/context"
storetypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/agent/testutils"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
func TestGetChatKBID(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
t.Run("WithTeamAndUser", func(t *testing.T) {
teamID := "5659-5504-2879"
userID := "4287-9400-2030-0504"
collectionID := assistant.GetChatKBID(teamID, userID)
// Should sanitize dashes to underscores
expected := "chat_5659_5504_2879_4287_9400_2030_0504"
assert.Equal(t, expected, collectionID)
t.Logf("✓ Collection ID with team: %s", collectionID)
})
t.Run("WithoutTeam", func(t *testing.T) {
teamID := ""
userID := "4287-9400-2030-0504"
collectionID := assistant.GetChatKBID(teamID, userID)
// Should use chat_user_ prefix
expected := "chat_user_4287_9400_2030_0504"
assert.Equal(t, expected, collectionID)
t.Logf("✓ Collection ID without team: %s", collectionID)
})
t.Run("Idempotent", func(t *testing.T) {
teamID := "test-team-123"
userID := "test-user-456"
id1 := assistant.GetChatKBID(teamID, userID)
id2 := assistant.GetChatKBID(teamID, userID)
id3 := assistant.GetChatKBID(teamID, userID)
// Same input should always produce same output
assert.Equal(t, id1, id2)
assert.Equal(t, id2, id3)
t.Logf("✓ Idempotent: %s", id1)
})
t.Run("SanitizeSpecialChars", func(t *testing.T) {
teamID := "team-with-dashes@123"
userID := "user.with.dots!"
collectionID := assistant.GetChatKBID(teamID, userID)
// Should only contain alphanumeric and underscores
assert.Regexp(t, "^[a-zA-Z0-9_]+$", collectionID)
t.Logf("✓ Sanitized ID: %s", collectionID)
})
t.Run("EmptyUserID", func(t *testing.T) {
teamID := "test-team"
userID := ""
collectionID := assistant.GetChatKBID(teamID, userID)
// Should handle empty user ID gracefully
expected := "chat_test_team_"
assert.Equal(t, expected, collectionID)
t.Logf("✓ Empty user ID handled: %s", collectionID)
})
}
func TestPrepareKBCollection(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t)
defer testutils.Clean(t)
// Get assistant
ast, err := assistant.Get("mohe")
require.NoError(t, err)
require.NotNil(t, ast)
// Note: KB collection is now created during user login (see openapi/user/login.go)
// These tests verify that InitializeConversation handles various scenarios gracefully
t.Run("InitializeWithAuthorizedInfo", func(t *testing.T) {
// Use unique IDs based on timestamp to avoid conflicts
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
teamID := fmt.Sprintf("test_team_%s", timestamp)
userID := fmt.Sprintf("test_user_%s", timestamp)
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
TeamID: teamID,
UserID: userID,
}, "test_chat_prepare_001")
opts := &agentcontext.Options{}
// InitializeConversation should succeed (KB collection created at login time)
err := ast.InitializeConversation(ctx, opts)
assert.NoError(t, err)
t.Logf("✓ InitializeConversation completed successfully")
})
t.Run("IdempotentInitialization", func(t *testing.T) {
// Use unique IDs based on timestamp to avoid conflicts
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
teamID := fmt.Sprintf("idem_team_%s", timestamp)
userID := fmt.Sprintf("idem_user_%s", timestamp)
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
TeamID: teamID,
UserID: userID,
}, "test_chat_idempotent")
opts := &agentcontext.Options{}
// Multiple calls should all succeed
err1 := ast.InitializeConversation(ctx, opts)
assert.NoError(t, err1)
err2 := ast.InitializeConversation(ctx, opts)
assert.NoError(t, err2)
err3 := ast.InitializeConversation(ctx, opts)
assert.NoError(t, err3)
t.Logf("✓ Idempotent initialization works correctly")
})
t.Run("HandleMissingAuthorizedInfo", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_no_auth") // Missing authorized info
opts := &agentcontext.Options{}
// Should not error, just return nil
err := ast.InitializeConversation(ctx, opts)
assert.NoError(t, err)
t.Logf("✓ Correctly handled missing authorized info")
})
t.Run("ConcurrentInitialization", func(t *testing.T) {
// Use unique IDs based on timestamp to avoid conflicts
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
teamID := fmt.Sprintf("concurrent_team_%s", timestamp)
userID := fmt.Sprintf("concurrent_user_%s", timestamp)
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
TeamID: teamID,
UserID: userID,
}, "test_chat_concurrent")
opts := &agentcontext.Options{}
// Launch 5 concurrent calls
var wg sync.WaitGroup
errors := make([]error, 5)
for i := 0; i < 5; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
errors[idx] = ast.InitializeConversation(ctx, opts)
}(i)
}
// Wait for all goroutines to complete
wg.Wait()
// All calls should succeed
for i, err := range errors {
assert.NoError(t, err, "Goroutine %d should not error", i)
}
t.Logf("✓ Concurrent initialization handled correctly")
})
}
func TestInitializeConversation(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("FullInitialization", func(t *testing.T) {
// Use unique IDs based on timestamp to avoid conflicts
timestamp := fmt.Sprintf("%d", time.Now().UnixNano())
teamID := fmt.Sprintf("init_team_%s", timestamp)
userID := fmt.Sprintf("init_user_%s", timestamp)
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
TeamID: teamID,
UserID: userID,
}, "test_init_chat_001")
opts := &agentcontext.Options{}
// Should initialize conversation without error
// Note: KB collection is now created during user login, not here
err := ast.InitializeConversation(ctx, opts)
assert.NoError(t, err)
t.Logf("✓ Conversation initialized successfully (KB collection created at login time)")
})
t.Run("SkipHistoryFlag", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
TeamID: "skip_team",
UserID: "skip_user",
}, "test_skip_history")
opts := &agentcontext.Options{
Skip: &agentcontext.Skip{
History: true,
},
}
// Should skip initialization when history flag is set
err := ast.InitializeConversation(ctx, opts)
assert.NoError(t, err)
t.Logf("✓ Correctly skipped with history flag")
})
}
// =============================================================================
// Buffer Integration Tests
// =============================================================================
func TestBufferInitialization(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("InitBufferForRootStack", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_001")
// Enter stack to simulate root stack
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
// Initialize buffer
ast.InitBuffer(ctx)
// Verify buffer was created
assert.NotNil(t, ctx.Buffer, "Buffer should be initialized for root stack")
assert.Equal(t, "test_chat_buffer_001", ctx.Buffer.ChatID())
assert.Equal(t, ast.ID, ctx.Buffer.AssistantID())
t.Logf("✓ Buffer initialized: chatID=%s, assistantID=%s", ctx.Buffer.ChatID(), ctx.Buffer.AssistantID())
})
t.Run("SkipBufferForNestedStack", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_nested")
// Enter root stack
_, _, doneRoot := agentcontext.EnterStack(ctx, "root_assistant", nil)
defer doneRoot()
// Enter nested stack
_, _, doneNested := agentcontext.EnterStack(ctx, "nested_assistant", nil)
defer doneNested()
// Try to initialize buffer (should be skipped for nested stack)
ast.InitBuffer(ctx)
// Buffer should be nil because we're not at root
assert.Nil(t, ctx.Buffer, "Buffer should not be initialized for nested stack")
t.Logf("✓ Buffer correctly skipped for nested stack")
})
t.Run("IdempotentBufferInit", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_idem")
// Enter stack
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
// Initialize buffer twice
ast.InitBuffer(ctx)
firstBuffer := ctx.Buffer
ast.InitBuffer(ctx)
secondBuffer := ctx.Buffer
// Should be the same buffer instance
assert.Same(t, firstBuffer, secondBuffer, "Buffer should be idempotent")
t.Logf("✓ Buffer initialization is idempotent")
})
}
func TestBufferUserInput(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
t.Run("BufferSimpleTextInput", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_001")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Create input messages
inputMessages := []agentcontext.Message{
{
Role: agentcontext.RoleUser,
Content: "Hello, how are you?",
},
}
// Buffer user input
ast.BufferUserInput(ctx, inputMessages)
// Verify buffer contains the message
messages := ctx.Buffer.GetMessages()
assert.Len(t, messages, 1, "Should have 1 buffered message")
assert.Equal(t, "user", messages[0].Role)
assert.Equal(t, "user_input", messages[0].Type)
assert.Equal(t, "Hello, how are you?", messages[0].Props["content"])
t.Logf("✓ User input buffered: %v", messages[0].Props)
})
t.Run("BufferMultipleMessages", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_multi")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Create multiple input messages
inputMessages := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "First message"},
{Role: agentcontext.RoleUser, Content: "Second message"},
}
// Buffer user input
ast.BufferUserInput(ctx, inputMessages)
// Verify buffer contains all messages
messages := ctx.Buffer.GetMessages()
assert.Len(t, messages, 2, "Should have 2 buffered messages")
assert.Equal(t, 1, messages[0].Sequence)
assert.Equal(t, 2, messages[1].Sequence)
t.Logf("✓ Multiple messages buffered with correct sequence")
})
t.Run("BufferWithNilBuffer", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_nil")
// Don't initialize buffer
inputMessages := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Test"},
}
// Should not panic
ast.BufferUserInput(ctx, inputMessages)
t.Logf("✓ BufferUserInput handles nil buffer gracefully")
})
}
func TestBufferStepTracking(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
t.Run("BeginAndCompleteStep", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_step_001")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Set some context memory data
if ctx.Memory != nil && ctx.Memory.Context != nil {
ctx.Memory.Context.Set("test_key", "test_value", 0)
}
// Begin a step
step := ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{
"messages": []string{"Hello"},
})
assert.NotNil(t, step, "Step should be created")
assert.Equal(t, agentcontext.StepTypeLLM, step.Type)
assert.Equal(t, agentcontext.StepStatusRunning, step.Status)
assert.NotEmpty(t, step.StackID)
// Complete the step
ast.CompleteStep(ctx, map[string]interface{}{
"content": "Response",
})
// Verify step is completed
steps := ctx.Buffer.GetAllSteps()
assert.Len(t, steps, 1)
assert.Equal(t, agentcontext.StepStatusCompleted, steps[0].Status)
assert.Equal(t, "Response", steps[0].Output["content"])
t.Logf("✓ Step tracking works correctly")
})
t.Run("ContextMemorySnapshotCapture", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_memory_001")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Set context memory data before step
require.NotNil(t, ctx.Memory)
require.NotNil(t, ctx.Memory.Context)
ctx.Memory.Context.Set("key1", "value1", 0)
ctx.Memory.Context.Set("key2", 123, 0)
// Begin step (should capture context memory snapshot)
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, nil)
// Verify context memory snapshot was captured
steps := ctx.Buffer.GetAllSteps()
require.Len(t, steps, 1)
assert.NotNil(t, steps[0].SpaceSnapshot)
assert.Equal(t, "value1", steps[0].SpaceSnapshot["key1"])
assert.Equal(t, 123, steps[0].SpaceSnapshot["key2"])
t.Logf("✓ Context memory snapshot captured: %v", steps[0].SpaceSnapshot)
})
t.Run("MultipleSteps", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_multi_step")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Step 1: hook_create
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, map[string]interface{}{"phase": "create"})
ast.CompleteStep(ctx, map[string]interface{}{"result": "created"})
// Step 2: llm
ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{"phase": "llm"})
ast.CompleteStep(ctx, map[string]interface{}{"result": "completed"})
// Step 3: hook_next
ast.BeginStep(ctx, agentcontext.StepTypeHookNext, map[string]interface{}{"phase": "next"})
ast.CompleteStep(ctx, map[string]interface{}{"result": "done"})
// Verify all steps
steps := ctx.Buffer.GetAllSteps()
assert.Len(t, steps, 3)
assert.Equal(t, agentcontext.StepTypeHookCreate, steps[0].Type)
assert.Equal(t, agentcontext.StepTypeLLM, steps[1].Type)
assert.Equal(t, agentcontext.StepTypeHookNext, steps[2].Type)
t.Logf("✓ Multiple steps tracked correctly")
})
}
func TestFlushBuffer(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
// Skip if chat store not available
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured, skipping flush tests")
}
t.Run("FlushOnSuccess", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_success_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Ensure chat exists
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// Add some messages to buffer
require.NotNil(t, ctx.Buffer, "Buffer should be initialized")
ctx.Buffer.AddUserInput("Test question", "")
ctx.Buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Test answer"}, "", "", ast.ID, nil)
// Add a step
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)
ast.CompleteStep(ctx, nil)
// Flush buffer (success case)
ast.FlushBuffer(ctx, agentcontext.StepStatusCompleted, nil)
// Verify messages were saved
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
assert.NoError(t, err)
assert.Len(t, messages, 2, "Should have 2 messages saved")
// Verify no resume records (success case)
resumes, err := chatStore.GetResume(chatID)
assert.NoError(t, err)
assert.Len(t, resumes, 0, "Should have no resume records on success")
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Buffer flushed on success: %d messages saved, no resume records", len(messages))
})
t.Run("FlushOnFailure", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_fail_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Ensure chat exists
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// Add messages
ctx.Buffer.AddUserInput("Test question", "")
// Add a step that will "fail"
ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{"test": "data"})
// Don't complete - simulate failure
// Flush buffer (failure case)
testErr := fmt.Errorf("simulated error")
ast.FlushBuffer(ctx, agentcontext.ResumeStatusFailed, testErr)
// Verify messages were saved
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
assert.NoError(t, err)
assert.Len(t, messages, 1, "Should have 1 message saved")
// Verify resume records were saved
resumes, err := chatStore.GetResume(chatID)
assert.NoError(t, err)
assert.Len(t, resumes, 1, "Should have 1 resume record on failure")
assert.Equal(t, agentcontext.ResumeStatusFailed, resumes[0].Status)
// Cleanup
chatStore.DeleteResume(chatID)
chatStore.DeleteChat(chatID)
t.Logf("✓ Buffer flushed on failure: messages and resume records saved")
})
t.Run("FlushOnInterrupt", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_interrupt_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Ensure chat exists
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// Add messages and steps
ctx.Buffer.AddUserInput("Test question", "")
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)
// Flush buffer (interrupt case)
ast.FlushBuffer(ctx, agentcontext.ResumeStatusInterrupted, nil)
// Verify resume records were saved with interrupted status
resumes, err := chatStore.GetResume(chatID)
assert.NoError(t, err)
assert.Len(t, resumes, 1, "Should have 1 resume record on interrupt")
assert.Equal(t, agentcontext.ResumeStatusInterrupted, resumes[0].Status)
// Cleanup
chatStore.DeleteResume(chatID)
chatStore.DeleteChat(chatID)
t.Logf("✓ Buffer flushed on interrupt: resume records saved with interrupted status")
})
t.Run("FlushWithModeAndConnector", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_mode_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Enter stack with connector and mode options
opts := &agentcontext.Options{
Connector: "deepseek.v3",
Mode: "task",
}
_, _, done := agentcontext.EnterStack(ctx, ast.ID, opts)
defer done()
ast.InitBuffer(ctx)
// Verify buffer has correct connector and mode
require.NotNil(t, ctx.Buffer, "Buffer should be initialized")
assert.Equal(t, "deepseek.v3", ctx.Buffer.Connector(), "Buffer should have connector set")
assert.Equal(t, "task", ctx.Buffer.Mode(), "Buffer should have mode set")
// Ensure chat exists
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// Add some messages to buffer
ctx.Buffer.AddUserInput("Test question for mode", "")
ctx.Buffer.AddAssistantMessage("M1", "text", map[string]interface{}{"content": "Test answer with mode"}, "", "", ast.ID, nil)
// Flush buffer
ast.FlushBuffer(ctx, agentcontext.StepStatusCompleted, nil)
// Verify messages were saved with connector and mode
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
assert.NoError(t, err)
assert.Len(t, messages, 2, "Should have 2 messages saved")
// Assistant message should have connector and mode
var assistantMsg *storetypes.Message
for _, msg := range messages {
if msg.Role == "assistant" {
assistantMsg = msg
break
}
}
require.NotNil(t, assistantMsg, "Should find assistant message")
assert.Equal(t, "deepseek.v3", assistantMsg.Connector, "Message should have connector")
assert.Equal(t, "task", assistantMsg.Mode, "Message should have mode")
// Verify chat was updated with last_connector and last_mode
chat, err := chatStore.GetChat(chatID)
assert.NoError(t, err)
assert.Equal(t, "deepseek.v3", chat.LastConnector, "Chat should have last_connector updated")
assert.Equal(t, "task", chat.LastMode, "Chat should have last_mode updated")
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Buffer flushed with mode and connector: connector=%s, mode=%s", chat.LastConnector, chat.LastMode)
})
}
func TestEnsureChat(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
// Skip if chat store not available
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured, skipping EnsureChat tests")
}
t.Run("CreateNewChat", func(t *testing.T) {
chatID := fmt.Sprintf("test_ensure_new_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Ensure chat creates it
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
// Verify chat was created
chat, err := chatStore.GetChat(chatID)
assert.NoError(t, err)
assert.NotNil(t, chat)
assert.Equal(t, chatID, chat.ChatID)
assert.Equal(t, ast.ID, chat.AssistantID)
assert.Equal(t, "active", chat.Status)
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ New chat created: %s", chatID)
})
t.Run("SkipExistingChat", func(t *testing.T) {
chatID := fmt.Sprintf("test_ensure_exist_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Create chat first
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Title: "Existing Chat",
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// EnsureChat should not error
err = ast.EnsureChat(ctx)
assert.NoError(t, err)
// Verify chat still has original title
chat, err := chatStore.GetChat(chatID)
assert.NoError(t, err)
assert.Equal(t, "Existing Chat", chat.Title)
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Existing chat preserved")
})
t.Run("SkipEmptyChatID", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "")
// Should not error with empty chat ID
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
t.Logf("✓ Empty chat ID handled gracefully")
})
t.Run("CreateChatWithPermissions", func(t *testing.T) {
chatID := fmt.Sprintf("test_ensure_perm_%s", uuid.New().String()[:8])
// Create context with authorized info
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
UserID: "test_user_001",
TeamID: "test_team_001",
TenantID: "test_tenant_001",
}, chatID)
// EnsureChat should create with permission fields
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
// Verify permission fields were saved
chat, err := chatStore.GetChat(chatID)
assert.NoError(t, err)
assert.NotNil(t, chat)
assert.Equal(t, "test_user_001", chat.CreatedBy, "CreatedBy should be set")
assert.Equal(t, "test_user_001", chat.UpdatedBy, "UpdatedBy should be set")
assert.Equal(t, "test_team_001", chat.TeamID, "TeamID should be set")
assert.Equal(t, "test_tenant_001", chat.TenantID, "TenantID should be set")
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Chat created with permission fields: user=%s, team=%s, tenant=%s",
chat.CreatedBy, chat.TeamID, chat.TenantID)
})
t.Run("SkipHistoryEnabled", func(t *testing.T) {
chatID := fmt.Sprintf("test_ensure_skip_%s", uuid.New().String()[:8])
// Create context
ctx := agentcontext.New(context.Background(), nil, chatID)
// Set up stack with Skip.History = true
ctx.Stack = &agentcontext.Stack{
ID: "test_stack",
AssistantID: ast.ID,
Depth: 0,
Options: &agentcontext.Options{
Skip: &agentcontext.Skip{
History: true,
},
},
}
// EnsureChat should NOT create chat when Skip.History is true
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
// Verify chat was NOT created
_, err = chatStore.GetChat(chatID)
assert.Error(t, err, "Chat should not be created when Skip.History is true")
t.Logf("✓ Chat not created when Skip.History is true")
})
}
// TestEnsureChatMetadata verifies that ctx.Metadata is persisted to the chat record.
// This is required for Host Agent: robot_id is passed in metadata so that
// ListChats with chat_id_prefix=robot_{id}_ can filter by robot.
func TestEnsureChatMetadata(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured, skipping metadata tests")
}
t.Run("MetadataPersisted", func(t *testing.T) {
chatID := fmt.Sprintf("robot_test_meta_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
UserID: "test_user_meta",
TeamID: "test_team_meta",
}, chatID)
ctx.Metadata = map[string]interface{}{
"robot_id": "robot_member_001",
}
err := ast.EnsureChat(ctx)
require.NoError(t, err)
chat, err := chatStore.GetChat(chatID)
require.NoError(t, err)
require.NotNil(t, chat)
require.NotNil(t, chat.Metadata, "Metadata should be persisted")
assert.Equal(t, "robot_member_001", chat.Metadata["robot_id"],
"robot_id should be stored in chat metadata")
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Chat metadata persisted: robot_id=%v", chat.Metadata["robot_id"])
})
t.Run("MetadataPersistedWithRobotChatIDPrefix", func(t *testing.T) {
// Simulate robot host chat_id format: robot_{member_id}_{timestamp}
memberID := "120004485525"
chatID := fmt.Sprintf("robot_%s_%d", memberID, time.Now().UnixMilli())
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
UserID: "test_user_robot",
TeamID: "test_team_robot",
}, chatID)
ctx.Metadata = map[string]interface{}{
"robot_id": memberID,
}
err := ast.EnsureChat(ctx)
require.NoError(t, err)
chat, err := chatStore.GetChat(chatID)
require.NoError(t, err)
require.NotNil(t, chat)
require.NotNil(t, chat.Metadata)
assert.Equal(t, memberID, chat.Metadata["robot_id"])
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Robot-prefix chat persisted with metadata: chat_id=%s", chatID)
})
t.Run("NilMetadataHandled", func(t *testing.T) {
chatID := fmt.Sprintf("test_meta_nil_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
ctx.Metadata = nil
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
chat, err := chatStore.GetChat(chatID)
require.NoError(t, err)
require.NotNil(t, chat)
// Metadata nil is acceptable
t.Logf("✓ Nil metadata handled gracefully")
// Cleanup
chatStore.DeleteChat(chatID)
})
t.Run("MetadataMultipleFields", func(t *testing.T) {
chatID := fmt.Sprintf("test_meta_multi_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
UserID: "test_user_multi",
TeamID: "test_team_multi",
}, chatID)
ctx.Metadata = map[string]interface{}{
"robot_id": "robot_multi_001",
"source": "mission_control",
}
err := ast.EnsureChat(ctx)
require.NoError(t, err)
chat, err := chatStore.GetChat(chatID)
require.NoError(t, err)
require.NotNil(t, chat)
require.NotNil(t, chat.Metadata)
assert.Equal(t, "robot_multi_001", chat.Metadata["robot_id"])
assert.Equal(t, "mission_control", chat.Metadata["source"])
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Multiple metadata fields persisted correctly")
})
}
func TestConvertBufferedTypes(t *testing.T) {
t.Run("ConvertBufferedMessages", func(t *testing.T) {
// Create buffered messages
buffered := []*agentcontext.BufferedMessage{
{
MessageID: "msg_001",
ChatID: "chat_001",
RequestID: "req_001",
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Hello"},
Sequence: 1,
CreatedAt: time.Now(),
},
{
MessageID: "msg_002",
ChatID: "chat_001",
RequestID: "req_001",
Role: "assistant",
Type: "text",
Props: map[string]interface{}{"content": "Hi there!"},
BlockID: "block_001",
AssistantID: "test_assistant",
Sequence: 2,
CreatedAt: time.Now(),
},
}
// Verify structure matches store types
assert.Len(t, buffered, 2)
assert.Equal(t, "user", buffered[0].Role)
assert.Equal(t, "assistant", buffered[1].Role)
assert.Equal(t, "block_001", buffered[1].BlockID)
t.Logf("✓ Buffered messages have correct structure")
})
t.Run("ConvertBufferedSteps", func(t *testing.T) {
// Create buffered steps
buffered := []*agentcontext.BufferedStep{
{
ResumeID: "resume_001",
ChatID: "chat_001",
RequestID: "req_001",
AssistantID: "test_assistant",
StackID: "stack_001",
StackDepth: 0,
Type: agentcontext.StepTypeLLM,
Status: agentcontext.ResumeStatusFailed,
Input: map[string]interface{}{"messages": []string{"Hello"}},
SpaceSnapshot: map[string]interface{}{"key": "value"},
Error: "Test error",
Sequence: 1,
CreatedAt: time.Now(),
},
}
// Verify structure
assert.Len(t, buffered, 1)
assert.Equal(t, agentcontext.StepTypeLLM, buffered[0].Type)
assert.Equal(t, agentcontext.ResumeStatusFailed, buffered[0].Status)
assert.Equal(t, "Test error", buffered[0].Error)
assert.Equal(t, "value", buffered[0].SpaceSnapshot["key"])
t.Logf("✓ Buffered steps have correct structure")
})
}

View file

@ -1,513 +0,0 @@
package handlers
import (
"time"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output"
"github.com/yaoapp/yao/agent/output/message"
)
// DefaultStreamHandler creates a default stream handler that sends messages via context
// This handler is used when no custom handler is provided
func DefaultStreamHandler(ctx *context.Context) message.StreamFunc {
// Create stream state manager
state := &streamState{
ctx: ctx,
inGroup: false,
currentGroupID: "",
messageSeq: 0,
}
return func(chunkType message.StreamChunkType, data []byte) int {
trace, _ := ctx.Trace()
if trace != nil {
trace.Info(i18n.T(ctx.Locale, "llm.handlers.stream.info"), map[string]any{"data": string(data)})
}
// Handle different chunk types
switch chunkType {
case message.ChunkStreamStart:
return state.handleStreamStart(data)
case message.ChunkMessageStart:
return state.handleMessageStart(data)
case message.ChunkText:
return state.handleText(data)
case message.ChunkThinking:
return state.handleThinking(data)
case message.ChunkToolCall:
return state.handleToolCall(data)
case message.ChunkExecute:
return state.handleExecute(data)
case message.ChunkMetadata:
return state.handleMetadata(data)
case message.ChunkError:
return state.handleError(data)
case message.ChunkMessageEnd:
return state.handleMessageEnd(data)
case message.ChunkStreamEnd:
return state.handleStreamEnd(data)
default:
// Unknown chunk type, continue
return 0
}
}
}
// streamState manages the state of the streaming process
type streamState struct {
ctx *context.Context
inGroup bool
currentGroupID string // Current group ID (shared by all chunks in the group)
currentType string // Track the current message type (text, thinking, tool_call)
buffer []byte
chunkCount int // Track number of chunks in current group
messageSeq int // Message sequence number (for generating readable IDs)
groupStartTime time.Time // Track when group started
lastExecStatus string // Last observed execute status in current group ("running", "completed", "error")
lastExecProps map[string]interface{} // Accumulated execute props for the current group (merged across chunks)
}
// handleStreamStart handles stream start event
func (s *streamState) handleStreamStart(data []byte) int {
// Send event message to indicate stream has started
// This is a lifecycle event, CUI clients can show it, OpenAI clients will ignore it
var startData message.EventStreamStartData
err := jsoniter.Unmarshal(data, &startData)
if err != nil {
log.Error("Failed to unmarshal stream start data: %v", err)
}
msg := output.NewEventMessage("stream_start", "Stream started", startData)
s.ctx.Send(msg)
return 0
}
// handleMessageStart handles message start event
func (s *streamState) handleMessageStart(data []byte) int {
// Parse message start data first to get the message ID
var startData message.EventMessageStartData
if err := jsoniter.Unmarshal(data, &startData); err != nil {
log.Error("Failed to unmarshal message start data: %v", err)
return 0
}
// Use the message ID from the start data, or generate one if not provided
messageID := startData.MessageID
if messageID == "" {
messageID = s.ctx.IDGenerator.GenerateMessageID()
startData.MessageID = messageID
}
// Auto-set ThreadID from Stack for nested agent calls
if startData.ThreadID == "" && s.ctx.Stack != nil && !s.ctx.Stack.IsRoot() {
startData.ThreadID = s.ctx.Stack.ID
}
s.inGroup = true
s.currentGroupID = messageID
s.buffer = []byte{}
s.chunkCount = 0
s.messageSeq = 0 // Reset message sequence for each message
s.groupStartTime = time.Now()
// Send message_start event
msg := output.NewEventMessage(message.EventMessageStart, "Message started", startData)
s.ctx.Send(msg)
return 0 // Continue
}
// handleText handles text content chunks
func (s *streamState) handleText(data []byte) int {
if len(data) == 0 {
return 0
}
// Track current message type
s.currentType = message.TypeText
// Append to buffer
s.buffer = append(s.buffer, data...)
s.chunkCount++
s.messageSeq++
// Send delta message
// - ChunkID: Unique chunk ID (C1, C2, C3...) for this fragment
// - MessageID: Same for all chunks of this logical message (frontend merges by message_id)
msg := &message.Message{
ChunkID: s.ctx.IDGenerator.GenerateChunkID(), // Unique chunk ID
MessageID: s.currentGroupID, // Message ID for merging (all chunks share this)
Type: message.TypeText,
Delta: true,
Props: map[string]interface{}{
"content": string(data),
},
}
if err := s.ctx.Send(msg); err != nil {
// Log error but continue streaming
return 0
}
return 0 // Continue
}
// handleThinking handles thinking/reasoning chunks
func (s *streamState) handleThinking(data []byte) int {
if len(data) == 0 {
return 0
}
// Track current message type
s.currentType = message.TypeThinking
// Append to buffer
s.buffer = append(s.buffer, data...)
s.chunkCount++
s.messageSeq++
// Send delta message
// - ChunkID: Unique chunk ID (C1, C2, C3...) for this fragment
// - MessageID: Same for all chunks of this logical message (frontend merges by message_id)
msg := &message.Message{
ChunkID: s.ctx.IDGenerator.GenerateChunkID(), // Unique chunk ID
MessageID: s.currentGroupID, // Message ID for merging (all chunks share this)
Type: message.TypeThinking,
Delta: true,
Props: map[string]interface{}{
"content": string(data),
},
}
if err := s.ctx.Send(msg); err != nil {
return 0
}
return 0 // Continue
}
// handleToolCall handles tool call chunks
func (s *streamState) handleToolCall(data []byte) int {
if len(data) == 0 {
return 0
}
// Track current message type
s.currentType = message.TypeToolCall
// Append to buffer for message_end event
s.buffer = append(s.buffer, data...)
s.chunkCount++
s.messageSeq++
// Parse the tool call delta data (JSON array from OpenAI)
var toolCallArray []map[string]interface{}
if err := jsoniter.Unmarshal(data, &toolCallArray); err != nil {
// If parse fails, log and skip this chunk
return 0
}
// Extract tool call fields from delta
// OpenAI delta typically has one element, but we handle arrays safely
var props map[string]interface{}
var deltaAction string
var deltaPath string
if len(toolCallArray) == 1 {
tc := toolCallArray[0]
props = map[string]interface{}{}
hasIdentity := false
if id, ok := tc["id"].(string); ok {
props["id"] = id
hasIdentity = true
}
if typ, ok := tc["type"].(string); ok {
props["type"] = typ
hasIdentity = true
}
if index, ok := tc["index"].(float64); ok {
props["index"] = int(index)
}
if fn, ok := tc["function"].(map[string]interface{}); ok {
if name, ok := fn["name"].(string); ok {
props["name"] = name
hasIdentity = true
}
if args, ok := fn["arguments"].(string); ok {
props["arguments"] = args
}
}
if hasIdentity {
// First chunk with id/name/type: merge so all fields are applied.
deltaAction = "merge"
} else if _, ok := props["arguments"]; ok {
// Subsequent chunk with only arguments fragment: append to arguments.
deltaAction = "append"
deltaPath = "arguments"
} else {
deltaAction = "merge"
}
} else {
// Multiple tool calls in delta (rare) - keep as array
props = map[string]interface{}{
"calls": toolCallArray,
}
deltaAction = "merge"
}
// Send delta message
// - ChunkID: Unique chunk ID (C1, C2, C3...) for this fragment
// - MessageID: Same for all chunks of this logical message (frontend merges by message_id)
// - DeltaAction: "append" for arguments chunks, "merge" for id/type/name chunks
// - DeltaPath: "arguments" when appending arguments field
// OpenAI sends: first chunk has id/type/name, subsequent chunks only have arguments fragments
msg := &message.Message{
ChunkID: s.ctx.IDGenerator.GenerateChunkID(), // Unique chunk ID
MessageID: s.currentGroupID, // Message ID for merging (all chunks share this)
Type: message.TypeToolCall,
Delta: true,
DeltaAction: deltaAction, // "append" for arguments, "merge" for static fields
DeltaPath: deltaPath, // "arguments" when appending
Props: props, // Flattened tool call fields
}
if err := s.ctx.Send(msg); err != nil {
return 0
}
return 0 // Continue
}
// handleExecute handles execute observation chunks from sandbox CLI agents.
// These represent tool actions observed inside the agent runtime (e.g., Bash, Read, Write).
func (s *streamState) handleExecute(data []byte) int {
if len(data) == 0 {
return 0
}
s.currentType = message.TypeExecute
s.buffer = append(s.buffer, data...)
s.chunkCount++
s.messageSeq++
var props map[string]interface{}
if err := jsoniter.Unmarshal(data, &props); err != nil {
return 0
}
if st, ok := props["status"].(string); ok {
s.lastExecStatus = st
}
if s.lastExecProps == nil {
s.lastExecProps = make(map[string]interface{})
}
for k, v := range props {
s.lastExecProps[k] = v
}
deltaAction := "merge"
msg := &message.Message{
ChunkID: s.ctx.IDGenerator.GenerateChunkID(),
MessageID: s.currentGroupID,
Type: message.TypeExecute,
Delta: true,
DeltaAction: deltaAction,
Props: props,
}
if err := s.ctx.Send(msg); err != nil {
return 0
}
return 0
}
// handleMetadata handles metadata chunks (usage, finish_reason, result_summary, etc.)
// For sandbox CLI agents, this carries token usage and result summaries.
func (s *streamState) handleMetadata(data []byte) int {
if len(data) == 0 {
return 0
}
var meta map[string]interface{}
if err := jsoniter.Unmarshal(data, &meta); err != nil {
return 0
}
if usage, ok := meta["usage"]; ok {
msg := output.NewEventMessage("token/usage", "", usage)
s.ctx.Send(msg)
}
if summary, ok := meta["result_summary"]; ok {
msg := output.NewEventMessage("result/summary", "", summary)
s.ctx.Send(msg)
}
return 0
}
// handleError handles error chunks
func (s *streamState) handleError(data []byte) int {
// Send error message
msg := output.NewErrorMessage(string(data), "stream_error")
s.ctx.Send(msg)
return 1 // Stop streaming on error
}
// handleMessageEnd handles message end event
func (s *streamState) handleMessageEnd(data []byte) int {
if !s.inGroup {
return 0
}
durationMs := time.Since(s.groupStartTime).Milliseconds()
// Use the tracked message type (thinking, text, tool_call, etc.)
msgType := s.currentType
if msgType == "" {
msgType = message.TypeText // Fallback to text if type not set
}
// Get ThreadID from Stack for nested agent calls
var threadID string
if s.ctx.Stack != nil && !s.ctx.Stack.IsRoot() {
threadID = s.ctx.Stack.ID
}
// Get BlockID from metadata if available
var blockID string
if s.ctx != nil {
if metadata := s.ctx.GetMessageMetadata(s.currentGroupID); metadata != nil {
blockID = metadata.BlockID
}
}
// Buffer the complete LLM message for storage
// Delta chunks are not stored, but we need to save the final complete content
// Skip if History is disabled in options
shouldSkipHistory := s.ctx.Stack != nil && s.ctx.Stack.Options != nil &&
s.ctx.Stack.Options.Skip != nil && s.ctx.Stack.Options.Skip.History
// Execute messages have two (or more) phases sharing the same message_id:
// 1. running / suspended / resumed — streamed for UI display only, NOT persisted
// 2. completed / error — the final state, persisted to the buffer
// Only persist when we have an explicit terminal status.
isExecuteFinal := msgType == message.TypeExecute &&
(s.lastExecStatus == "completed" || s.lastExecStatus == "error")
skipExecute := msgType == message.TypeExecute && !isExecuteFinal
if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory && !skipExecute {
assistantID := ""
if s.ctx.Stack != nil {
assistantID = s.ctx.Stack.AssistantID
}
var props map[string]interface{}
switch msgType {
case message.TypeToolCall:
var toolCallData interface{}
if err := jsoniter.Unmarshal(s.buffer, &toolCallData); err == nil {
props = map[string]interface{}{
"calls": toolCallData,
}
} else {
props = map[string]interface{}{
"content": string(s.buffer),
}
}
case message.TypeExecute:
if s.lastExecProps != nil {
props = make(map[string]interface{}, len(s.lastExecProps))
for k, v := range s.lastExecProps {
props[k] = v
}
} else {
props = map[string]interface{}{
"content": string(s.buffer),
}
}
default:
props = map[string]interface{}{
"content": string(s.buffer),
}
}
s.ctx.Buffer.AddAssistantMessage(
s.currentGroupID,
msgType,
props,
blockID,
threadID,
assistantID,
nil,
)
}
// Build EventMessageEndData with complete content
endData := message.EventMessageEndData{
MessageID: s.currentGroupID, // Use the message ID
Type: msgType,
Timestamp: time.Now().UnixMilli(),
ThreadID: threadID, // Include ThreadID for concurrent stream identification
DurationMs: durationMs,
ChunkCount: s.chunkCount,
Status: "completed",
Extra: map[string]interface{}{
"content": string(s.buffer), // Include complete content in the event
},
}
// Send message_end event
msg := output.NewEventMessage(message.EventMessageEnd, "Message completed", endData)
s.ctx.Send(msg)
// Reset state
s.inGroup = false
s.currentGroupID = ""
s.currentType = ""
s.buffer = []byte{}
s.chunkCount = 0
s.lastExecStatus = ""
s.lastExecProps = nil
return 0 // Continue
}
// handleStreamEnd handles stream end event
func (s *streamState) handleStreamEnd(data []byte) int {
// Parse the stream end data
var endData message.EventStreamEndData
if err := jsoniter.Unmarshal(data, &endData); err != nil {
log.Error("Failed to parse stream_end data: %v", err)
s.ctx.Flush()
return 0
}
// Send stream_end event as a message to frontend
msg := output.NewEventMessage("stream_end", "Stream completed", endData)
s.ctx.Send(msg)
// Flush any remaining data
s.ctx.Flush()
return 0 // Continue (stream will end naturally)
}

View file

@ -1,432 +0,0 @@
package assistant
import (
"fmt"
"reflect"
jsoniter "github.com/json-iterator/go"
agentcontext "github.com/yaoapp/yao/agent/context"
storetypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/trace/types"
)
// =============================================================================
// Chat History Management
// =============================================================================
// HistoryResult represents the result of history processing
type HistoryResult struct {
InputMessages []agentcontext.Message // Clean input messages (without overlap)
FullMessages []agentcontext.Message // Full messages (history + clean input)
}
// getHistorySize returns the history size with priority: opts.HistorySize > storeSetting.MaxSize > default (20)
func getHistorySize(opts *agentcontext.Options) int {
const defaultHistorySize = 20
if opts != nil && opts.HistorySize > 0 {
return opts.HistorySize
}
if setting := GetStoreSetting(); setting != nil && setting.MaxSize > 0 {
return setting.MaxSize
}
return defaultHistorySize
}
// WithHistory merges the input messages with chat history and traces it
// Returns HistoryResult containing:
// - InputMessages: cleaned input (overlap removed)
// - FullMessages: history + clean input merged
func (ast *Assistant) WithHistory(ctx *agentcontext.Context, input []agentcontext.Message, agentNode types.Node, options ...*agentcontext.Options) (*HistoryResult, error) {
// Get options
var opts *agentcontext.Options
if len(options) > 0 && options[0] != nil {
opts = options[0]
}
// SKIP: History (for internal calls like title/prompt etc.)
if opts != nil && opts.Skip != nil && opts.Skip.History {
result := &HistoryResult{
InputMessages: input,
FullMessages: input,
}
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
return result, nil
}
// Resolve history size: opts.HistorySize > storeSetting.MaxSize > default (20)
maxSize := getHistorySize(opts)
// Load history from store
historyMessages, err := ast.loadHistory(ctx, maxSize)
if err != nil {
// Log warning but continue without history
ctx.Logger.Warn("Failed to load history for chat=%s: %v", ctx.ChatID, err)
result := &HistoryResult{
InputMessages: input,
FullMessages: input,
}
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
return result, nil
}
// If no history, return input as is
if len(historyMessages) == 0 {
ctx.Logger.HistoryLoad(0, maxSize)
result := &HistoryResult{
InputMessages: input,
FullMessages: input,
}
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
return result, nil
}
// Log history loaded
ctx.Logger.HistoryLoad(len(historyMessages), maxSize)
// Find overlap between history and input
// Some external clients may include history in their requests
overlapIndex := ast.findOverlapIndex(historyMessages, input)
// Remove overlap from input
cleanInput := input
if overlapIndex > 0 {
cleanInput = input[overlapIndex:]
ctx.Logger.HistoryOverlap(overlapIndex)
}
// Merge history with clean input
fullMessages := make([]agentcontext.Message, 0, len(historyMessages)+len(cleanInput))
fullMessages = append(fullMessages, historyMessages...)
fullMessages = append(fullMessages, cleanInput...)
result := &HistoryResult{
InputMessages: cleanInput,
FullMessages: fullMessages,
}
// Log the chat history
ast.traceAgentHistory(ctx, agentNode, result.FullMessages)
return result, nil
}
// loadHistory loads chat history from the store
// Returns the most recent maxSize messages, ordered by time (oldest first)
func (ast *Assistant) loadHistory(ctx *agentcontext.Context, maxSize int) ([]agentcontext.Message, error) {
// Check if chat ID is available
if ctx.ChatID == "" {
return nil, nil
}
// Get chat store
chatStore := GetChatStore()
if chatStore == nil {
return nil, nil
}
// Load messages from store with limit
filter := storetypes.MessageFilter{
Limit: maxSize,
}
storeMessages, err := chatStore.GetMessages(ctx.ChatID, filter)
if err != nil {
return nil, fmt.Errorf("failed to get messages: %w", err)
}
if len(storeMessages) == 0 {
return nil, nil
}
// Convert store messages to context messages
messages := make([]agentcontext.Message, 0, len(storeMessages))
for _, msg := range storeMessages {
// Only include user and assistant messages for LLM context
// Skip internal types like loading, event, etc.
if msg.Role != "user" && msg.Role != "assistant" {
continue
}
// Convert store message to context message
ctxMsg := ast.convertStoreMessageToContext(msg)
if ctxMsg != nil {
messages = append(messages, *ctxMsg)
}
}
return messages, nil
}
// convertStoreMessageToContext converts a store message to a context message
func (ast *Assistant) convertStoreMessageToContext(msg *storetypes.Message) *agentcontext.Message {
if msg == nil {
return nil
}
// Handle special message types:
// - tool_call/action: convert to historical summary text for LLM context
// - loading/event: skip (pure UI/lifecycle signals, no semantic value)
// - error: kept as-is so LLM can help troubleshoot issues
switch msg.Type {
case "tool_call":
return ast.convertToolCallToContext(msg)
case "action":
return ast.convertActionToContext(msg)
case "loading", "event":
return nil
}
// Extract content from Props
content := ast.extractContentFromProps(msg.Props, msg.Type)
if content == nil {
return nil
}
// Build context message
ctxMsg := &agentcontext.Message{
Role: agentcontext.MessageRole(msg.Role),
Content: content,
}
// Handle name field
if msg.Props != nil {
if name, ok := msg.Props["name"].(string); ok && name != "" {
ctxMsg.Name = &name
}
}
return ctxMsg
}
// extractContentFromProps extracts the content from message Props based on message type
func (ast *Assistant) extractContentFromProps(props map[string]interface{}, msgType string) interface{} {
if props == nil {
return nil
}
// For user input, content is stored directly in props["content"]
if msgType == "user_input" {
return props["content"]
}
// For text type messages
if msgType == "text" {
if text, ok := props["text"].(string); ok {
return text
}
// Also try content field
if content, ok := props["content"].(string); ok {
return content
}
}
// For other types, try to extract content or text
if content, ok := props["content"]; ok {
return content
}
if text, ok := props["text"]; ok {
return text
}
return nil
}
// convertToolCallToContext converts a tool_call store message to a historical summary text message.
// This allows the LLM to understand what tools were previously called without re-invoking them.
//
// Supports two Props formats:
// - Standard ToolCallProps: {"name": "tool_name", "arguments": "{...}"}
// - Raw stream chunks: {"content": "[{\"index\":0,\"id\":\"call_...\",\"function\":{\"name\":\"tool\"}}][...]"}
func (ast *Assistant) convertToolCallToContext(msg *storetypes.Message) *agentcontext.Message {
if msg.Props == nil {
return nil
}
// Try standard ToolCallProps format first
if name, ok := msg.Props["name"].(string); ok && name != "" {
args, _ := msg.Props["arguments"].(string)
const maxArgsLen = 500
if len(args) > maxArgsLen {
args = args[:maxArgsLen] + "..."
}
return &agentcontext.Message{
Role: agentcontext.RoleAssistant,
Content: fmt.Sprintf("[Historical Tool Call Summary] Called tool \"%s\" with arguments: %s", name, args),
}
}
// Try raw stream chunk format: {"content": "[...][...]..."}
// Each chunk is a JSON array like [{"index":0,"id":"call_...","function":{"name":"echo__ping"}}]
// Subsequent chunks append arguments: [{"index":0,"function":{"arguments":"..."}}]
if raw, ok := msg.Props["content"].(string); ok && raw != "" {
name, args := parseToolCallRawChunks(raw)
if name == "" {
return nil
}
const maxArgsLen = 500
if len(args) > maxArgsLen {
args = args[:maxArgsLen] + "..."
}
return &agentcontext.Message{
Role: agentcontext.RoleAssistant,
Content: fmt.Sprintf("[Historical Tool Call Summary] Called tool \"%s\" with arguments: %s", name, args),
}
}
return nil
}
// parseToolCallRawChunks parses concatenated raw stream chunks to extract tool name and arguments.
// Input format: "[{...}][{...}][{...}]" — multiple JSON arrays concatenated without separator.
func parseToolCallRawChunks(raw string) (name, args string) {
// Split concatenated JSON arrays: "][" is the boundary
// e.g. "[{...}][{...}]" → ["[{...}]", "[{...}]"]
chunks := splitJSONArrays(raw)
var argParts []string
for _, chunk := range chunks {
var items []map[string]interface{}
if err := jsoniter.UnmarshalFromString(chunk, &items); err != nil || len(items) == 0 {
continue
}
item := items[0]
if fn, ok := item["function"].(map[string]interface{}); ok {
if n, ok := fn["name"].(string); ok && n != "" && name == "" {
name = n
}
if a, ok := fn["arguments"].(string); ok && a != "" {
argParts = append(argParts, a)
}
}
}
args = ""
for _, part := range argParts {
args += part
}
return name, args
}
// splitJSONArrays splits a string of concatenated JSON arrays "[...][...][...]" into individual arrays.
func splitJSONArrays(s string) []string {
var result []string
depth := 0
start := -1
for i, ch := range s {
switch ch {
case '[':
if depth == 0 {
start = i
}
depth++
case ']':
depth--
if depth == 0 && start >= 0 {
result = append(result, s[start:i+1])
start = -1
}
}
}
return result
}
// convertActionToContext converts an action store message to a historical summary text message.
// This allows the LLM to understand what system actions were previously executed.
func (ast *Assistant) convertActionToContext(msg *storetypes.Message) *agentcontext.Message {
if msg.Props == nil {
return nil
}
name, _ := msg.Props["name"].(string)
if name == "" {
return nil
}
payload := ""
if msg.Props["payload"] != nil {
if payloadStr, err := jsoniter.MarshalToString(msg.Props["payload"]); err == nil {
const maxPayloadLen = 500
if len(payloadStr) > maxPayloadLen {
payloadStr = payloadStr[:maxPayloadLen] + "..."
}
payload = payloadStr
}
}
if payload != "" {
return &agentcontext.Message{
Role: agentcontext.RoleAssistant,
Content: fmt.Sprintf("[Historical Action Summary] Executed action \"%s\" with payload: %s", name, payload),
}
}
return &agentcontext.Message{
Role: agentcontext.RoleAssistant,
Content: fmt.Sprintf("[Historical Action Summary] Executed action \"%s\"", name),
}
}
// findOverlapIndex finds the index in input where history messages end
// Returns the number of input messages that overlap with history
func (ast *Assistant) findOverlapIndex(history, input []agentcontext.Message) int {
if len(history) == 0 || len(input) == 0 {
return 0
}
// We need to find the longest suffix of history that matches a prefix of input
// Start from the end of history and try to match with the beginning of input
maxOverlap := len(history)
if maxOverlap > len(input) {
maxOverlap = len(input)
}
// Try different overlap lengths, starting from the largest possible
for overlapLen := maxOverlap; overlapLen > 0; overlapLen-- {
// Check if the last 'overlapLen' messages of history match the first 'overlapLen' of input
historyStart := len(history) - overlapLen
matched := true
for i := 0; i < overlapLen; i++ {
if !ast.messagesMatch(history[historyStart+i], input[i]) {
matched = false
break
}
}
if matched {
return overlapLen
}
}
return 0
}
// messagesMatch checks if two messages are equivalent
func (ast *Assistant) messagesMatch(a, b agentcontext.Message) bool {
// Must have same role
if a.Role != b.Role {
return false
}
// Compare content
return ast.contentMatches(a.Content, b.Content)
}
// contentMatches compares two content values for equality
func (ast *Assistant) contentMatches(a, b interface{}) bool {
// Handle nil cases
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
// If both are strings, compare directly
aStr, aIsStr := a.(string)
bStr, bIsStr := b.(string)
if aIsStr && bIsStr {
return aStr == bStr
}
// For complex content (arrays, etc.), use deep equal
return reflect.DeepEqual(a, b)
}

View file

@ -1,994 +0,0 @@
package assistant_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
storetypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/agent/testutils"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
// =============================================================================
// Helper Functions
// =============================================================================
// newHistoryTestContext creates a test context for history tests
func newHistoryTestContext(chatID string) *agentcontext.Context {
authorized := &oauthtypes.AuthorizedInfo{
Subject: "test-user",
UserID: "history-test-user",
TeamID: "history-test-team",
TenantID: "history-test-tenant",
}
ctx := agentcontext.New(context.Background(), authorized, chatID)
ctx.AssistantID = "tests.history"
ctx.Locale = "en-us"
ctx.Client = agentcontext.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = agentcontext.RefererAPI
ctx.Accept = agentcontext.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
return ctx
}
// =============================================================================
// WithHistory Tests
// =============================================================================
func TestWithHistory(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Get assistant
ast, err := assistant.Get("tests.history")
require.NoError(t, err)
require.NotNil(t, ast)
// Get chat store for setup/cleanup
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured, skipping history tests")
}
t.Run("NoHistory", func(t *testing.T) {
chatID := fmt.Sprintf("test_history_none_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
// Create chat without any messages
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer chatStore.DeleteChat(chatID)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Hello, this is my first message"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// With no history, InputMessages and FullMessages should be the same as input
assert.Equal(t, input, result.InputMessages)
assert.Equal(t, input, result.FullMessages)
t.Log("✓ No history: input returned as is")
})
t.Run("WithExistingHistory", func(t *testing.T) {
chatID := fmt.Sprintf("test_history_exist_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add history messages
historyMessages := []*storetypes.Message{
{
MessageID: fmt.Sprintf("hist_msg_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Previous question"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-2 * time.Minute),
},
{
MessageID: fmt.Sprintf("hist_msg_2_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_%s", reqID),
Role: "assistant",
Type: "text",
Props: map[string]interface{}{"text": "Previous answer"},
Sequence: 2,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-1 * time.Minute),
},
}
err = chatStore.SaveMessages(chatID, historyMessages)
require.NoError(t, err)
// New input message
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "New question"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// InputMessages should be unchanged (no overlap)
assert.Equal(t, input, result.InputMessages)
// FullMessages should have history + input
assert.Len(t, result.FullMessages, 3) // 2 history + 1 new
// Verify order: history first, then input
assert.Equal(t, agentcontext.RoleUser, result.FullMessages[0].Role)
assert.Equal(t, "Previous question", result.FullMessages[0].Content)
assert.Equal(t, agentcontext.RoleAssistant, result.FullMessages[1].Role)
assert.Equal(t, "Previous answer", result.FullMessages[1].Content)
assert.Equal(t, agentcontext.RoleUser, result.FullMessages[2].Role)
assert.Equal(t, "New question", result.FullMessages[2].Content)
t.Log("✓ History merged correctly with new input")
})
t.Run("SkipHistoryOption", func(t *testing.T) {
chatID := fmt.Sprintf("test_history_skip_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat with history
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add history message
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("skip_hist_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_skip_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Should be skipped"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now(),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Only this should appear"},
}
// Use Skip.History option
opts := &agentcontext.Options{
Skip: &agentcontext.Skip{
History: true,
},
}
result, err := ast.WithHistory(ctx, input, nil, opts)
require.NoError(t, err)
require.NotNil(t, result)
// Both should be same as input (history skipped)
assert.Equal(t, input, result.InputMessages)
assert.Equal(t, input, result.FullMessages)
assert.Len(t, result.FullMessages, 1)
t.Log("✓ History skipped when Skip.History=true")
})
t.Run("OverlapDetection", func(t *testing.T) {
chatID := fmt.Sprintf("test_history_overlap_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add history messages
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("overlap_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_overlap_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Message one"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-3 * time.Minute),
},
{
MessageID: fmt.Sprintf("overlap_2_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_overlap_%s", reqID),
Role: "assistant",
Type: "text",
Props: map[string]interface{}{"text": "Response one"},
Sequence: 2,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-2 * time.Minute),
},
{
MessageID: fmt.Sprintf("overlap_3_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_overlap_2_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Message two"},
Sequence: 3,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-1 * time.Minute),
},
})
require.NoError(t, err)
// Input that overlaps with history (includes last messages)
// Some clients send full history + new message
input := []agentcontext.Message{
{Role: agentcontext.RoleAssistant, Content: "Response one"}, // Overlap
{Role: agentcontext.RoleUser, Content: "Message two"}, // Overlap
{Role: agentcontext.RoleUser, Content: "New message"}, // New
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// InputMessages should have overlap removed
assert.Len(t, result.InputMessages, 1, "Should remove 2 overlapping messages")
assert.Equal(t, "New message", result.InputMessages[0].Content)
// FullMessages should be history + clean input
assert.Len(t, result.FullMessages, 4) // 3 history + 1 new
t.Log("✓ Overlap detected and removed from input")
})
t.Run("EmptyChatID", func(t *testing.T) {
ctx := newHistoryTestContext("")
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "No chat ID"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// With empty chat ID, should return input as is
assert.Equal(t, input, result.InputMessages)
assert.Equal(t, input, result.FullMessages)
t.Log("✓ Empty chat ID handled gracefully")
})
t.Run("MultipleUserMessages", func(t *testing.T) {
chatID := fmt.Sprintf("test_history_multi_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat with history
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add history
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("multi_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_multi_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "First"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-1 * time.Minute),
},
})
require.NoError(t, err)
// Multiple input messages
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Second"},
{Role: agentcontext.RoleUser, Content: "Third"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
assert.Len(t, result.InputMessages, 2)
assert.Len(t, result.FullMessages, 3) // 1 history + 2 new
t.Log("✓ Multiple input messages handled correctly")
})
}
// =============================================================================
// History Load Tests
// =============================================================================
func TestHistoryLoading(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("tests.history")
require.NoError(t, err)
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured")
}
t.Run("FilterNonConversationTypes", func(t *testing.T) {
chatID := fmt.Sprintf("test_filter_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add various message types (only user/assistant roles allowed by DB constraint)
// loadHistory filters by role (user/assistant only) and converts based on type:
// - loading/event: skipped (no semantic value)
// - tool_call/action: converted to historical summary text
// - text/user_input/error: kept as-is
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("filter_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_filter_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "User message"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-3 * time.Minute),
},
{
MessageID: fmt.Sprintf("filter_2_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_filter_%s", reqID),
Role: "assistant",
Type: "loading",
Props: map[string]interface{}{"text": "Loading..."},
Sequence: 2,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-2 * time.Minute),
},
{
MessageID: fmt.Sprintf("filter_3_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_filter_%s", reqID),
Role: "assistant",
Type: "text",
Props: map[string]interface{}{"text": "Assistant response"},
Sequence: 3,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-1 * time.Minute),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "New input"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// loading type is skipped (no semantic value)
// History: user_input + text = 2 messages; plus 1 new input = 3 total
assert.Len(t, result.FullMessages, 3)
// Verify only user and assistant roles
for _, msg := range result.FullMessages {
assert.True(t, msg.Role == agentcontext.RoleUser || msg.Role == agentcontext.RoleAssistant,
"Expected user or assistant role, got: %s", msg.Role)
}
t.Log("✓ Loading type filtered, user/assistant roles kept")
})
t.Run("ToolCallConvertedToSummary", func(t *testing.T) {
chatID := fmt.Sprintf("test_toolcall_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add tool_call messages in both formats
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("tc_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_tc_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "echo 3 ping 4"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-3 * time.Minute),
},
// Raw stream chunk format (actual DB format)
{
MessageID: fmt.Sprintf("tc_2_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_tc_%s", reqID),
Role: "assistant",
Type: "tool_call",
Props: map[string]interface{}{"content": `[{"index":0,"id":"call_abc","type":"function","function":{"name":"echo__ping"}}][{"index":0,"function":{"arguments":"{\"count\":3}"}}]`},
Sequence: 2,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-2 * time.Minute),
},
// Standard ToolCallProps format
{
MessageID: fmt.Sprintf("tc_3_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_tc_%s", reqID),
Role: "assistant",
Type: "tool_call",
Props: map[string]interface{}{"name": "echo__echo", "arguments": `{"message":"hello"}`},
Sequence: 3,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-1 * time.Minute),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "echo 5 ping 6"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// 1 user_input + 2 tool_call summaries + 1 new input = 4
assert.Len(t, result.FullMessages, 4)
// Verify tool_call messages are converted to summary text
tcMsg1 := result.FullMessages[1]
assert.Equal(t, agentcontext.RoleAssistant, tcMsg1.Role)
assert.Contains(t, tcMsg1.Content, "[Historical Tool Call Summary]")
assert.Contains(t, tcMsg1.Content, "echo__ping")
assert.Contains(t, tcMsg1.Content, `{"count":3}`)
tcMsg2 := result.FullMessages[2]
assert.Equal(t, agentcontext.RoleAssistant, tcMsg2.Role)
assert.Contains(t, tcMsg2.Content, "[Historical Tool Call Summary]")
assert.Contains(t, tcMsg2.Content, "echo__echo")
assert.Contains(t, tcMsg2.Content, `{"message":"hello"}`)
t.Log("✓ Tool call messages converted to historical summaries")
})
t.Run("ActionConvertedToSummary", func(t *testing.T) {
chatID := fmt.Sprintf("test_action_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("act_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_act_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Do something"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-2 * time.Minute),
},
// Action with payload
{
MessageID: fmt.Sprintf("act_2_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_act_%s", reqID),
Role: "assistant",
Type: "action",
Props: map[string]interface{}{
"name": "robot.execute",
"payload": map[string]interface{}{"goals": "test goal", "robot_id": "12345"},
},
Sequence: 2,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-1 * time.Minute),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "What happened?"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// 1 user_input + 1 action summary + 1 new input = 3
assert.Len(t, result.FullMessages, 3)
actMsg := result.FullMessages[1]
assert.Equal(t, agentcontext.RoleAssistant, actMsg.Role)
assert.Contains(t, actMsg.Content, "[Historical Action Summary]")
assert.Contains(t, actMsg.Content, "robot.execute")
assert.Contains(t, actMsg.Content, "test goal")
assert.Contains(t, actMsg.Content, "12345")
t.Log("✓ Action messages converted to historical summaries with payload")
})
t.Run("ActionWithoutPayload", func(t *testing.T) {
chatID := fmt.Sprintf("test_action_nopay_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("actnp_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_actnp_%s", reqID),
Role: "assistant",
Type: "action",
Props: map[string]interface{}{"name": "navigate"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now(),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "What happened?"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// 1 action summary + 1 new input = 2
assert.Len(t, result.FullMessages, 2)
actMsg := result.FullMessages[0]
assert.Equal(t, agentcontext.RoleAssistant, actMsg.Role)
assert.Contains(t, actMsg.Content, "[Historical Action Summary]")
assert.Contains(t, actMsg.Content, "navigate")
assert.NotContains(t, actMsg.Content, "payload")
t.Log("✓ Action without payload handled correctly")
})
t.Run("ContentExtraction", func(t *testing.T) {
chatID := fmt.Sprintf("test_extract_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add messages with different content formats
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("extract_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_extract_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "User content from props.content"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-2 * time.Minute),
},
{
MessageID: fmt.Sprintf("extract_2_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_extract_%s", reqID),
Role: "assistant",
Type: "text",
Props: map[string]interface{}{"text": "Assistant content from props.text"},
Sequence: 2,
AssistantID: ast.ID,
CreatedAt: time.Now().Add(-1 * time.Minute),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "New message"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// Verify content was extracted correctly
assert.Len(t, result.FullMessages, 3)
assert.Equal(t, "User content from props.content", result.FullMessages[0].Content)
assert.Equal(t, "Assistant content from props.text", result.FullMessages[1].Content)
t.Log("✓ Content extracted correctly from different formats")
})
}
// =============================================================================
// Edge Cases Tests
// =============================================================================
func TestHistoryEdgeCases(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("tests.history")
require.NoError(t, err)
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured")
}
t.Run("EmptyInput", func(t *testing.T) {
chatID := fmt.Sprintf("test_empty_input_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat with history
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("empty_input_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_empty_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Previous"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now(),
},
})
require.NoError(t, err)
// Empty input
input := []agentcontext.Message{}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// Should return history only
assert.Empty(t, result.InputMessages)
assert.Len(t, result.FullMessages, 1)
t.Log("✓ Empty input handled correctly")
})
t.Run("FullOverlap", func(t *testing.T) {
chatID := fmt.Sprintf("test_full_overlap_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add history
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("full_overlap_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_full_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Exact same message"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now(),
},
})
require.NoError(t, err)
// Input is exactly the same as history
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Exact same message"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// Full overlap: clean input should be empty
assert.Empty(t, result.InputMessages)
// FullMessages should be just history (no duplicates)
assert.Len(t, result.FullMessages, 1)
t.Log("✓ Full overlap handled correctly")
})
t.Run("NonExistentChat", func(t *testing.T) {
chatID := "non_existent_chat_12345"
ctx := newHistoryTestContext(chatID)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Message to non-existent chat"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// Should return input as is (no history found)
assert.Equal(t, input, result.InputMessages)
assert.Equal(t, input, result.FullMessages)
t.Log("✓ Non-existent chat handled gracefully")
})
t.Run("MessageWithName", func(t *testing.T) {
chatID := fmt.Sprintf("test_name_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add message with name
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("name_msg_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_name_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Message with name", "name": "John"},
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now(),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "New message"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// First message should have name
assert.Len(t, result.FullMessages, 2)
assert.NotNil(t, result.FullMessages[0].Name)
assert.Equal(t, "John", *result.FullMessages[0].Name)
t.Log("✓ Message name field preserved")
})
t.Run("EmptyContent", func(t *testing.T) {
chatID := fmt.Sprintf("test_empty_content_%s", uuid.New().String()[:8])
ctx := newHistoryTestContext(chatID)
reqID := uuid.New().String()[:8]
// Create chat
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
defer func() {
chatStore.DeleteMessages(chatID, nil)
chatStore.DeleteChat(chatID)
}()
// Add message with empty content in props
err = chatStore.SaveMessages(chatID, []*storetypes.Message{
{
MessageID: fmt.Sprintf("empty_content_1_%s", reqID),
ChatID: chatID,
RequestID: fmt.Sprintf("req_empty_content_%s", reqID),
Role: "user",
Type: "user_input",
Props: map[string]interface{}{}, // empty props (no content)
Sequence: 1,
AssistantID: ast.ID,
CreatedAt: time.Now(),
},
})
require.NoError(t, err)
input := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "New message"},
}
result, err := ast.WithHistory(ctx, input, nil)
require.NoError(t, err)
require.NotNil(t, result)
// Message with empty props should be skipped (no content extractable)
// Only new input should be present
assert.Len(t, result.FullMessages, 1)
assert.Equal(t, "New message", result.FullMessages[0].Content)
t.Log("✓ Empty content handled gracefully (message skipped)")
})
}

View file

@ -1,357 +0,0 @@
# Performance Test Report
**Test Date**: November 28, 2025
**System**: Yao Agent Assistant - Create Hook
**Hardware**: Apple M2 Max, ARM64, macOS 25.1.0
---
## Executive Summary
All tests passed with 100% success rate. The system demonstrates production-ready performance with stable memory usage and predictable response times.
**Key Metrics:**
- ✅ **Concurrent Capacity**: 1,000 operations @ 100 goroutines
- ✅ **Response Time**: 1.57ms average (hook execution only)
- ✅ **Memory Stable**: ≤1 MB growth under load
- ✅ **Success Rate**: 100% (1,000/1,000 validated)
---
## Performance Benchmarks
### Single Request Performance
| Scenario | Mode | Time/op | Memory/op | Allocs/op |
| -------- | ----------- | ------- | --------- | --------- |
| Simple | Standard | 1.44 ms | 45 KB | 827 |
| Simple | Performance | 0.33 ms | 33 KB | 789 |
| Business | Standard | 3.33 ms | 95 KB | 1,570 |
| Business | Performance | 0.35 ms | 33 KB | 805 |
**Note**: Standard mode creates/disposes V8 isolate per request. Performance mode reuses isolates from pool.
### Concurrent Performance
| Scenario | Mode | Time/op | Memory/op | Allocs/op |
| ------------------- | ----------- | ------- | --------- | --------- |
| Simple Concurrent | Standard | 0.42 ms | 46 KB | 829 |
| Simple Concurrent | Performance | 0.35 ms | 33 KB | 789 |
| Business Concurrent | Standard | 0.64 ms | 89 KB | 1,457 |
| Business Concurrent | Performance | 0.35 ms | 33 KB | 786 |
**Observation**: Concurrent execution shows better performance than sequential in standard mode due to parallel isolate creation.
---
## Stress Test Results
### Basic Tests
**Simple Scenario** (100 iterations):
- Duration: 0.34s
- Memory: 470 MB → 471 MB (0 MB growth)
- Result: ✅ Stable
**MCP Integration** (50 iterations):
- Duration: 0.40s
- Memory: 472 MB → 471 MB (0 MB growth)
- Result: ✅ No leaks
**Full Workflow** (30 iterations, MCP + DB + Trace):
- Duration: 0.39s
- Average: 12.90 ms/op
- Memory: 472 MB → 471 MB (0 MB growth)
- Result: ✅ All components working
### Concurrent Stress Test ⭐
**Configuration:**
- Goroutines: 100
- Iterations: 10 per goroutine
- Total operations: 1,000
- Scenarios: Mixed (simple, mcp_health, mcp_tools, full_workflow)
**Results:**
- Duration: 1.57 seconds
- Average: 1.57 ms/op
- Throughput: ~636 ops/second
- Success: 1,000/1,000 (100%)
- Memory: 472 MB → 473 MB (1 MB growth)
- Validation: All responses correct
**Scenario Distribution:**
- simple: 250 ops (25%)
- mcp_health: 250 ops (25%)
- mcp_tools: 250 ops (25%)
- full_workflow: 250 ops (25%)
---
## Memory Analysis
### Memory Leak Tests
All memory leak tests passed with acceptable thresholds:
**Standard Mode** (1,000 iterations):
- Growth: 11.65 MB (12.2 KB/iteration)
- Threshold: <15 KB/iteration
- Status: ✅ Pass
**Performance Mode** (1,000 iterations):
- Growth: -0.15 MB (negative = GC working)
- Status: ✅ Pass
**Business Scenarios** (200 iterations each):
- Growth: 12-15 KB/iteration
- Status: ✅ All pass
**Concurrent Load** (1,000 iterations):
- Growth: 1.73 MB (1.8 KB/iteration)
- Status: ✅ Excellent
### Goroutine Behavior
**Observation**: Each request creates 2 goroutines (trace pubsub + state worker) that exit asynchronously after `Release()`.
**Measured Growth**: 2.0 goroutines/iteration
- Initial: 106 → Final: 122 (after 10 iterations)
- Threshold: <5 goroutines/iteration
- Status: ✅ Expected behavior (not a leak)
**Root Cause**: Asynchronous cleanup - goroutines exit when channels close, but scheduling takes time. This is normal Go concurrency behavior.
---
## Capacity Planning
### Single Instance Capacity
**Hook Execution Only** (measured):
```
Response Time: 1.57ms
Goroutines: 100 tested, stable
Throughput: ~636 ops/second actual
```
**Complete Request Flow** (estimated):
```
Hook Execution: 1.57ms
LLM API Call: 500-2000ms (typical)
Network + Parsing: 50-100ms
Total: ~1000ms per request
```
### Production Estimates
**Conservative Capacity** (50% safety factor):
| User Activity | Requests/Min | Concurrent Online Users |
| ------------------- | ------------ | ----------------------- |
| Light (3 req/min) | 3,000 total | 1,000 online |
| Normal (6 req/min) | 3,000 total | 500 online |
| Active (15 req/min) | 3,000 total | 200 online |
| Heavy (30 req/min) | 3,000 total | 100 online |
**Calculation Basis:**
- 100 goroutines proven stable
- ~1 request/second per goroutine
- Base: 100 req/s = 6,000 req/min
- With 50% safety: 3,000 req/min sustained
**Recommendation**: Start with 500-1,000 concurrent online users per instance, monitor and scale horizontally as needed.
**Note**: "Concurrent online users" means users actively using the system at the same time, not total registered users.
### Horizontal Scaling
```
1 instance → 500-1,000 concurrent online users
2 instances → 1,000-2,000 concurrent online users
5 instances → 2,500-5,000 concurrent online users
10 instances → 5,000-10,000 concurrent online users
```
---
## Component Verification
### MCP Integration ✅
- ListTools: Working
- CallTool: Working (ping, status)
- Resource operations: Working
- Prompt operations: Working
- Performance: <3ms per operation
### Trace Management ✅
- Node creation: <1ms
- 20+ nodes per operation: No issues
- Memory cleanup: Effective
- Goroutine cleanup: Asynchronous (expected)
### Context Management ✅
- Creation: Fast
- Release: Working (cascading cleanup)
- Memory: No leaks detected
- Thread-safe: Yes
### Database Integration ✅
- Query execution: Working
- Connection pooling: Efficient
- Error handling: Robust
---
## Reliability Metrics
**Test Coverage:**
- Total tests: 21
- Tests passed: 21 (100%)
- Tests failed: 0
- Flaky tests: 0
**Error Rate:**
- Operations: 1,200+
- Errors: 0
- Rate: 0.00%
**Data Integrity:**
- Message validation: 100%
- Metadata validation: 100%
- Scenario matching: 100%
---
## Known Behaviors
### Goroutine Accumulation
**Observation**: ~2 goroutines created per request that exit asynchronously.
**Root Cause**:
- Trace creates 2 background goroutines: `pubsub.forward()` + `stateWorker()`
- These exit when channels close (via `Release()`)
- Exit is asynchronous - takes 5-15ms after `Release()`
- In rapid iterations, new goroutines start before old ones finish exiting
**Impact**:
- Temporary accumulation during high load
- No unbounded growth (goroutines eventually exit)
- Go runtime handles this efficiently
- Not a memory leak
**Status**: ✅ Expected behavior, no action needed
---
## Recommendations
### Production Deployment
**Ready to Deploy**: Yes
**Suggested Configuration:**
- Start with 1-2 instances
- Target: 500-1,000 concurrent users per instance
- V8 Mode: Standard (safer) or Performance (faster)
- Health check: Monitor goroutine count (<10,000)
### Monitoring
**Key Metrics to Track:**
1. Response time (alert if >100ms sustained)
2. Goroutine count (alert if >10,000)
3. Memory usage (alert if >1GB growth/hour)
4. Error rate (alert if >1%)
### Scaling Triggers
**Scale Up When:**
- Response time >50ms average (sustained 5 min)
- Goroutine count >5,000 (approaching limits)
- CPU >70% (need more capacity)
**Scale Out When:**
- Need >1,000 concurrent users
- Multi-region deployment required
- Geographic latency optimization needed
---
## Conclusions
### System Status: **Production Ready**
**Strengths:**
- Fast response times (1-3ms for hook execution)
- Stable memory usage (no leaks detected)
- Excellent concurrent performance (100+ goroutines stable)
- 100% test success rate with validation
- Clean resource management with proper cleanup
**Suitable For:**
- SaaS platforms (500-1,000 concurrent online users per instance)
- Enterprise applications requiring high reliability
- Systems with 100-1,000 concurrent online users
- Mission-critical AI agent deployments
**Performance Rating**: A (Excellent)
**Capacity Rating**: Mid-stage SaaS (Series A/B ready)
---
## Test Execution Summary
```
Platform: darwin/arm64
CPU: Apple M2 Max
Go Version: 1.25.0
Test Duration: 19.8 seconds
Unit Tests: 21 passed
Benchmarks: 8 completed
Stress Tests: 5 passed (1,000 ops validated)
Memory Tests: 7 passed
Goroutine Tests: 4 passed (behavior documented)
Overall: 100% PASS ✅
```
---
**Report Generated**: November 28, 2025
**Test Framework**: Go testing + testify
**Validation**: Complete (all responses verified)
**Status**: PRODUCTION READY

View file

@ -1,106 +0,0 @@
package hook
import (
"encoding/json"
"fmt"
"github.com/yaoapp/gou/runtime/v8/bridge"
"github.com/yaoapp/yao/agent/context"
)
// Create create a new assistant
// opts is optional - if provided, will be adjusted based on hook response
func (s *Script) Create(ctx *context.Context, messages []context.Message, opts ...*context.Options) (*context.HookCreateResponse, *context.Options, error) {
// Get or create options
var options *context.Options
if len(opts) > 0 && opts[0] != nil {
options = opts[0]
} else {
options = &context.Options{}
}
// Execute hook with ctx, messages, and options (convert options to map for JS)
optionsMap := options.ToMap()
res, err := s.Execute(ctx, "Create", messages, optionsMap)
if err != nil {
return nil, nil, err
}
response, err := s.getHookCreateResponse(res)
if err != nil {
return nil, nil, err
}
// Apply adjustments from the response
if response != nil {
s.applyContextAdjustments(ctx, response)
s.applyOptionsAdjustments(options, response)
}
return response, options, nil
}
// applyContextAdjustments applies session-level field overrides from the hook response back to the context
func (s *Script) applyContextAdjustments(ctx *context.Context, response *context.HookCreateResponse) {
// Note: AssistantID cannot be overridden - it's set at initialization and immutable
// Override locale if provided (session-level)
if response.Locale != "" {
ctx.Locale = response.Locale
}
// Override theme if provided (session-level)
if response.Theme != "" {
ctx.Theme = response.Theme
}
// Override route if provided (session-level)
if response.Route != "" {
ctx.Route = response.Route
}
// Merge or override metadata if provided (session-level)
if len(response.Metadata) > 0 {
if ctx.Metadata == nil {
ctx.Metadata = make(map[string]interface{})
}
// Merge metadata - response metadata takes precedence
for key, value := range response.Metadata {
ctx.Metadata[key] = value
}
}
}
// applyOptionsAdjustments applies call-level field overrides from the hook response to options
func (s *Script) applyOptionsAdjustments(opts *context.Options, response *context.HookCreateResponse) {
// Override connector if provided (call-level parameter)
if response.Connector != "" {
opts.Connector = response.Connector
}
}
// getHookCreateResponse convert the result to a HookCreateResponse
func (s *Script) getHookCreateResponse(res interface{}) (*context.HookCreateResponse, error) {
// Handle nil result
if res == nil {
return nil, nil
}
// Handle undefined result (treat as nil)
if _, ok := res.(bridge.UndefinedT); ok {
return nil, nil
}
// Marshal to JSON and unmarshal to HookCreateResponse
raw, err := json.Marshal(res)
if err != nil {
return nil, fmt.Errorf("failed to marshal result: %w", err)
}
var response context.HookCreateResponse
if err := json.Unmarshal(raw, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal to HookCreateResponse: %w", err)
}
return &response, nil
}

View file

@ -1,326 +0,0 @@
package hook_test
import (
stdContext "context"
"testing"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/test"
)
// ============================================================================
// Simple Scenario Benchmarks
// ============================================================================
// BenchmarkSimpleStandardMode benchmarks simple scenario in standard V8 mode
// Run with: go test -bench=BenchmarkSimpleStandardMode -benchmem -benchtime=100x
func BenchmarkSimpleStandardMode(b *testing.B) {
testutils.Prepare(&testing.T{})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx := newBenchContext("bench-simple-standard", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
b.Fatalf("Create failed: %s", err.Error())
}
}
}
// BenchmarkSimplePerformanceMode benchmarks simple scenario in performance V8 mode
// Run with: go test -bench=BenchmarkSimplePerformanceMode -benchmem -benchtime=100x
func BenchmarkSimplePerformanceMode(b *testing.B) {
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
ctx := newBenchContext("bench-simple-performance", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
b.Fatalf("Create failed: %s", err.Error())
}
}
}
// ============================================================================
// Business Scenario Benchmarks (with Process calls, DB access, etc.)
// ============================================================================
// BenchmarkBusinessStandardMode benchmarks business scenarios in standard V8 mode
// Run with: go test -bench=BenchmarkBusinessStandardMode -benchmem -benchtime=100x
func BenchmarkBusinessStandardMode(b *testing.B) {
testutils.Prepare(&testing.T{})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
scenarios := getBusinessScenarios()
b.ResetTimer()
for i := 0; i < b.N; i++ {
scenario := scenarios[i%len(scenarios)]
ctx := newBenchContext("bench-business-standard", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: scenario.content},
})
if err != nil {
b.Errorf("%s failed: %s", scenario.name, err.Error())
}
}
}
// BenchmarkBusinessPerformanceMode benchmarks business scenarios in performance V8 mode
// Run with: go test -bench=BenchmarkBusinessPerformanceMode -benchmem -benchtime=100x
func BenchmarkBusinessPerformanceMode(b *testing.B) {
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
scenarios := getBusinessScenarios()
b.ResetTimer()
for i := 0; i < b.N; i++ {
scenario := scenarios[i%len(scenarios)]
ctx := newBenchContext("bench-business-performance", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: scenario.content},
})
if err != nil {
b.Errorf("%s failed: %s", scenario.name, err.Error())
}
}
}
// ============================================================================
// Concurrent Benchmarks
// ============================================================================
// BenchmarkConcurrentSimpleStandardMode benchmarks simple concurrent scenario in standard V8 mode
// Simulates concurrent users with isolate creation/disposal per request
// Run with: go test -bench=BenchmarkConcurrentSimpleStandardMode -benchmem -benchtime=100x
func BenchmarkConcurrentSimpleStandardMode(b *testing.B) {
testutils.Prepare(&testing.T{})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
ctx := newBenchContext("bench-concurrent-simple-standard", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
b.Errorf("Create failed (iteration %d): %s", i, err.Error())
}
i++
}
})
}
// BenchmarkConcurrentSimplePerformanceMode benchmarks simple concurrent scenario in performance V8 mode
// Simulates 100 users simultaneously using the system with isolate pool
// Run with: go test -bench=BenchmarkConcurrentSimplePerformanceMode -benchmem -benchtime=100x
func BenchmarkConcurrentSimplePerformanceMode(b *testing.B) {
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
ctx := newBenchContext("bench-concurrent-simple", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
b.Errorf("Create failed (iteration %d): %s", i, err.Error())
}
i++
}
})
}
// BenchmarkConcurrentBusinessStandardMode benchmarks concurrent business scenarios in standard V8 mode
// Tests various scenarios with concurrent users and isolate creation/disposal per request
// Run with: go test -bench=BenchmarkConcurrentBusinessStandardMode -benchmem -benchtime=100x
func BenchmarkConcurrentBusinessStandardMode(b *testing.B) {
testutils.Prepare(&testing.T{})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
scenarios := getBusinessScenarios()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
scenario := scenarios[i%len(scenarios)]
ctx := newBenchContext("bench-concurrent-business-standard", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: scenario.content},
})
if err != nil {
b.Errorf("%s failed (iteration %d): %s", scenario.name, i, err.Error())
}
i++
}
})
}
// BenchmarkConcurrentBusinessPerformanceMode benchmarks concurrent business scenarios in performance V8 mode
// Tests various scenarios with 100 concurrent users with isolate pool
// Run with: go test -bench=BenchmarkConcurrentBusinessPerformanceMode -benchmem -benchtime=100x
func BenchmarkConcurrentBusinessPerformanceMode(b *testing.B) {
testutils.Prepare(&testing.T{}, test.PrepareOption{V8Mode: "performance"})
defer testutils.Clean(&testing.T{})
agent, err := assistant.Get("tests.create")
if err != nil {
b.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
b.Fatalf("Assistant has no script")
}
scenarios := getBusinessScenarios()
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
i := 0
for pb.Next() {
scenario := scenarios[i%len(scenarios)]
ctx := newBenchContext("bench-concurrent-business", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: scenario.content},
})
if err != nil {
b.Errorf("%s failed (iteration %d): %s", scenario.name, i, err.Error())
}
i++
}
})
}
// ============================================================================
// Helper Functions
// ============================================================================
// getBusinessScenarios returns the business test scenarios
func getBusinessScenarios() []struct {
name string
content string
} {
return []struct {
name string
content string
}{
{name: "FullResponse", content: "return_full"},
{name: "PartialResponse", content: "return_partial"},
{name: "ProcessCall", content: "return_process"},
{name: "ContextAdjustment", content: "adjust_context"},
{name: "NestedScriptCall", content: "nested_script_call"},
{name: "DeepNestedCall", content: "deep_nested_call"},
}
}
// newBenchContext creates a minimal context for benchmarking
func newBenchContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "bench-user",
ClientID: "bench-client",
UserID: "bench-user-123",
TeamID: "bench-team-456",
TenantID: "bench-tenant-789",
Constraints: types.DataConstraints{
TeamOnly: true,
Extra: map[string]interface{}{
"department": "engineering",
},
},
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "BenchAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}

View file

@ -1,642 +0,0 @@
package hook_test
import (
stdContext "context"
"runtime"
"testing"
"time"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/test"
)
// ============================================================================
// Memory Leak Detection Tests
// ============================================================================
// TestMemoryLeakStandardMode checks for memory leaks in standard V8 mode
// Run with: go test -run=TestMemoryLeakStandardMode -v
func TestMemoryLeakStandardMode(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Warm up - execute a few times to stabilize memory
for i := 0; i < 10; i++ {
ctx := newMemTestContext("warmup", "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
ctx.Release()
}
// Force GC and get baseline memory
runtime.GC()
time.Sleep(100 * time.Millisecond)
var baseline runtime.MemStats
runtime.ReadMemStats(&baseline)
// Execute many iterations
iterations := 1000
for i := 0; i < iterations; i++ {
ctx := newMemTestContext("mem-test-standard", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
}
// Release context resources
ctx.Release()
// Periodic GC to help detect leaks faster
if i%100 == 0 {
runtime.GC()
}
}
// Force GC and check final memory
runtime.GC()
time.Sleep(100 * time.Millisecond)
var final runtime.MemStats
runtime.ReadMemStats(&final)
// Calculate memory growth
baselineHeap := baseline.HeapAlloc
finalHeap := final.HeapAlloc
growth := int64(finalHeap) - int64(baselineHeap)
growthPerIteration := float64(growth) / float64(iterations)
t.Logf("Memory Statistics (Standard Mode):")
t.Logf(" Iterations: %d", iterations)
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baselineHeap, float64(baselineHeap)/1024/1024)
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", finalHeap, float64(finalHeap)/1024/1024)
t.Logf(" Total Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
t.Logf(" Growth per iteration: %.2f bytes", growthPerIteration)
t.Logf(" Total Alloc: %d bytes (%.2f MB)", final.TotalAlloc, float64(final.TotalAlloc)/1024/1024)
t.Logf(" Mallocs: %d", final.Mallocs)
t.Logf(" Frees: %d", final.Frees)
t.Logf(" Live Objects: %d", final.Mallocs-final.Frees)
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
// Check for memory leak
// Standard mode creates/disposes isolates per request, so some overhead is expected
// Allow up to 20KB growth per iteration as threshold
// This accounts for V8 isolate creation/disposal overhead and bridge management
// Significant leaks would show much higher growth rates (50KB+)
maxGrowthPerIteration := 20480.0 // 20 KB
if growthPerIteration > maxGrowthPerIteration {
t.Errorf("Possible memory leak detected: %.2f bytes/iteration (threshold: %.2f bytes/iteration)",
growthPerIteration, maxGrowthPerIteration)
} else {
t.Logf("✓ Memory growth is within acceptable range (%.2f bytes/iteration)", growthPerIteration)
}
}
// TestMemoryLeakPerformanceMode checks for memory leaks in performance V8 mode
// Run with: go test -run=TestMemoryLeakPerformanceMode -v
func TestMemoryLeakPerformanceMode(t *testing.T) {
testutils.Prepare(t, test.PrepareOption{V8Mode: "performance"})
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Warm up - execute a few times to stabilize memory and fill isolate pool
for i := 0; i < 20; i++ {
ctx := newMemTestContext("warmup", "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
ctx.Release()
}
// Force GC and get baseline memory
runtime.GC()
time.Sleep(100 * time.Millisecond)
var baseline runtime.MemStats
runtime.ReadMemStats(&baseline)
// Execute many iterations
iterations := 1000
for i := 0; i < iterations; i++ {
ctx := newMemTestContext("mem-test-performance", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
}
// Release context resources
ctx.Release()
// Periodic GC
if i%100 == 0 {
runtime.GC()
}
}
// Force GC and check final memory
runtime.GC()
time.Sleep(100 * time.Millisecond)
var final runtime.MemStats
runtime.ReadMemStats(&final)
// Calculate memory growth
baselineHeap := baseline.HeapAlloc
finalHeap := final.HeapAlloc
growth := int64(finalHeap) - int64(baselineHeap)
growthPerIteration := float64(growth) / float64(iterations)
t.Logf("Memory Statistics (Performance Mode):")
t.Logf(" Iterations: %d", iterations)
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baselineHeap, float64(baselineHeap)/1024/1024)
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", finalHeap, float64(finalHeap)/1024/1024)
t.Logf(" Total Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
t.Logf(" Growth per iteration: %.2f bytes", growthPerIteration)
t.Logf(" Total Alloc: %d bytes (%.2f MB)", final.TotalAlloc, float64(final.TotalAlloc)/1024/1024)
t.Logf(" Mallocs: %d", final.Mallocs)
t.Logf(" Frees: %d", final.Frees)
t.Logf(" Live Objects: %d", final.Mallocs-final.Frees)
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
// Performance mode should have less growth due to isolate reuse
// Allow up to 5KB per iteration as threshold
maxGrowthPerIteration := 5120.0
if growthPerIteration > maxGrowthPerIteration {
t.Errorf("Possible memory leak detected: %.2f bytes/iteration (threshold: %.2f bytes/iteration)",
growthPerIteration, maxGrowthPerIteration)
} else {
t.Logf("✓ Memory growth is within acceptable range")
}
}
// TestMemoryLeakBusinessScenarios checks for memory leaks with business logic
// Run with: go test -run=TestMemoryLeakBusinessScenarios -v
func TestMemoryLeakBusinessScenarios(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
scenarios := []struct {
name string
content string
}{
{name: "FullResponse", content: "return_full"},
{name: "PartialResponse", content: "return_partial"},
{name: "ProcessCall", content: "return_process"},
{name: "ContextAdjustment", content: "adjust_context"},
{name: "NestedScriptCall", content: "nested_script_call"},
{name: "DeepNestedCall", content: "deep_nested_call"},
}
// Warm up
for i := 0; i < 10; i++ {
ctx := newMemTestContext("warmup", "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "return_full"},
})
ctx.Release()
}
// Test each scenario
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
// Get baseline
runtime.GC()
time.Sleep(50 * time.Millisecond)
var baseline runtime.MemStats
runtime.ReadMemStats(&baseline)
// Execute iterations (reduced to avoid V8 OOM)
iterations := 200
for i := 0; i < iterations; i++ {
ctx := newMemTestContext("mem-test-business", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: scenario.content},
})
if err != nil {
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
}
ctx.Release()
if i%50 == 0 {
runtime.GC()
}
}
// Check final memory
runtime.GC()
time.Sleep(50 * time.Millisecond)
var final runtime.MemStats
runtime.ReadMemStats(&final)
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
growthPerIteration := float64(growth) / float64(iterations)
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
// Business scenarios may have more memory usage due to complex operations
// Allow up to 20KB per iteration as threshold
// Note: Some scenarios like ContextAdjustment generate dynamic timestamps,
// causing slightly higher memory usage. Real leaks would show 50KB+ growth.
maxGrowthPerIteration := 20480.0
if growthPerIteration > maxGrowthPerIteration {
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
growthPerIteration, maxGrowthPerIteration)
} else {
t.Logf(" ✓ Memory growth is within acceptable range")
}
})
}
}
// TestMemoryLeakConcurrent checks for memory leaks under concurrent load
// Run with: go test -run=TestMemoryLeakConcurrent -v
func TestMemoryLeakConcurrent(t *testing.T) {
testutils.Prepare(t, test.PrepareOption{V8Mode: "performance"})
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Warm up
for i := 0; i < 20; i++ {
ctx := newMemTestContext("warmup", "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
ctx.Release()
}
// Get baseline
runtime.GC()
time.Sleep(100 * time.Millisecond)
var baseline runtime.MemStats
runtime.ReadMemStats(&baseline)
// Run concurrent load
iterations := 1000
concurrency := 10
iterPerGoroutine := iterations / concurrency
done := make(chan bool, concurrency)
for g := 0; g < concurrency; g++ {
go func(id int) {
defer func() { done <- true }()
for i := 0; i < iterPerGoroutine; i++ {
ctx := newMemTestContext("mem-test-concurrent", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
t.Errorf("Goroutine %d failed at iteration %d: %s", id, i, err.Error())
}
ctx.Release()
}
}(g)
}
// Wait for all goroutines
for g := 0; g < concurrency; g++ {
<-done
}
// Check final memory
runtime.GC()
time.Sleep(100 * time.Millisecond)
var final runtime.MemStats
runtime.ReadMemStats(&final)
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
growthPerIteration := float64(growth) / float64(iterations)
t.Logf("Memory Statistics (Concurrent Load):")
t.Logf(" Iterations: %d", iterations)
t.Logf(" Concurrency: %d", concurrency)
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
// Concurrent scenarios may have slightly more overhead
maxGrowthPerIteration := 10240.0
if growthPerIteration > maxGrowthPerIteration {
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
growthPerIteration, maxGrowthPerIteration)
} else {
t.Logf("✓ Memory growth is within acceptable range")
}
}
// TestMemoryLeakNestedCalls checks for memory leaks with nested script calls
// Run with: go test -run=TestMemoryLeakNestedCalls -v
func TestMemoryLeakNestedCalls(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Warm up
for i := 0; i < 10; i++ {
ctx := newMemTestContext("warmup", "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "nested_script_call"},
})
ctx.Release()
}
// Get baseline
runtime.GC()
time.Sleep(100 * time.Millisecond)
var baseline runtime.MemStats
runtime.ReadMemStats(&baseline)
// Execute iterations with nested calls
// Nested calls: hook -> scripts.tests.create.NestedCall -> GetRoles/GetRole -> models
iterations := 200
for i := 0; i < iterations; i++ {
ctx := newMemTestContext("mem-test-nested", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "deep_nested_call"},
})
if err != nil {
t.Errorf("Nested call failed at iteration %d: %s", i, err.Error())
}
ctx.Release()
if i%50 == 0 {
runtime.GC()
}
}
// Check final memory
runtime.GC()
time.Sleep(100 * time.Millisecond)
var final runtime.MemStats
runtime.ReadMemStats(&final)
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
growthPerIteration := float64(growth) / float64(iterations)
t.Logf("Memory Statistics (Nested Calls):")
t.Logf(" Iterations: %d", iterations)
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
// Nested calls involve database operations, so allow more overhead
// Allow up to 20KB per iteration as threshold
maxGrowthPerIteration := 20480.0
if growthPerIteration > maxGrowthPerIteration {
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
growthPerIteration, maxGrowthPerIteration)
} else {
t.Logf("✓ Memory growth is within acceptable range")
}
}
// TestMemoryLeakNestedConcurrent checks for memory leaks with concurrent nested calls
// Run with: go test -run=TestMemoryLeakNestedConcurrent -v
func TestMemoryLeakNestedConcurrent(t *testing.T) {
testutils.Prepare(t, test.PrepareOption{V8Mode: "performance"})
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Warm up
for i := 0; i < 20; i++ {
ctx := newMemTestContext("warmup", "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "nested_script_call"},
})
ctx.Release()
}
// Get baseline
runtime.GC()
time.Sleep(100 * time.Millisecond)
var baseline runtime.MemStats
runtime.ReadMemStats(&baseline)
// Run concurrent nested calls
iterations := 500
concurrency := 10
iterPerGoroutine := iterations / concurrency
done := make(chan bool, concurrency)
for g := 0; g < concurrency; g++ {
go func(id int) {
defer func() { done <- true }()
for i := 0; i < iterPerGoroutine; i++ {
ctx := newMemTestContext("mem-test-nested-concurrent", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "deep_nested_call"},
})
if err != nil {
t.Errorf("Goroutine %d nested call failed at iteration %d: %s", id, i, err.Error())
}
ctx.Release()
}
}(g)
}
// Wait for all goroutines
for g := 0; g < concurrency; g++ {
<-done
}
// Check final memory
runtime.GC()
time.Sleep(100 * time.Millisecond)
var final runtime.MemStats
runtime.ReadMemStats(&final)
growth := int64(final.HeapAlloc) - int64(baseline.HeapAlloc)
growthPerIteration := float64(growth) / float64(iterations)
t.Logf("Memory Statistics (Concurrent Nested Calls):")
t.Logf(" Iterations: %d", iterations)
t.Logf(" Concurrency: %d", concurrency)
t.Logf(" Baseline HeapAlloc: %d bytes (%.2f MB)", baseline.HeapAlloc, float64(baseline.HeapAlloc)/1024/1024)
t.Logf(" Final HeapAlloc: %d bytes (%.2f MB)", final.HeapAlloc, float64(final.HeapAlloc)/1024/1024)
t.Logf(" Growth: %d bytes (%.2f MB)", growth, float64(growth)/1024/1024)
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
t.Logf(" GC Runs: %d", final.NumGC-baseline.NumGC)
// Concurrent nested calls with database operations
// Allow up to 25KB per iteration as threshold
maxGrowthPerIteration := 25600.0
if growthPerIteration > maxGrowthPerIteration {
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
growthPerIteration, maxGrowthPerIteration)
} else {
t.Logf("✓ Memory growth is within acceptable range")
}
}
// TestIsolateDisposal verifies that isolates are properly disposed in standard mode
// Run with: go test -run=TestIsolateDisposal -v
func TestIsolateDisposal(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Track goroutine count to detect goroutine leaks
initialGoroutines := runtime.NumGoroutine()
// Execute multiple iterations
iterations := 100
for i := 0; i < iterations; i++ {
ctx := newMemTestContext("disposal-test", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
}
ctx.Release()
}
// Give time for cleanup
time.Sleep(200 * time.Millisecond)
runtime.GC()
time.Sleep(200 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
goroutineGrowth := finalGoroutines - initialGoroutines
t.Logf("Goroutine Statistics:")
t.Logf(" Initial: %d", initialGoroutines)
t.Logf(" Final: %d", finalGoroutines)
t.Logf(" Growth: %d", goroutineGrowth)
// Allow some goroutine growth for runtime internals
//
// ROOT CAUSE ANALYSIS:
// Each Create() call creates a Trace, which starts 2 goroutines:
// 1. trace/pubsub.(*PubSub).forward() - PubSub event forwarding
// 2. trace.(*manager).startStateWorker() - State machine worker
//
// These goroutines exit when Release() closes their channels, but:
// - Exit is ASYNCHRONOUS (goroutine needs to reach select statement)
// - Go runtime needs time to schedule and cleanup
// - In rapid iterations, new goroutines are created before old ones fully exit
//
// This is NOT a true leak:
// ✓ Goroutines eventually exit (channels are closed)
// ✓ No unbounded growth (they will be GC'd)
// ✓ Typical pattern for async cleanup in Go
//
// Acceptable: ~2 goroutines per iteration (trace pubsub + state worker)
// Concerning: >5 goroutines per iteration (indicates goroutines NOT exiting)
maxGoroutineGrowthPerIteration := 5.0
growthPerIteration := float64(goroutineGrowth) / float64(iterations)
if growthPerIteration > maxGoroutineGrowthPerIteration {
t.Errorf("Goroutine leak detected: %.2f goroutines per iteration (threshold: %.2f)",
growthPerIteration, maxGoroutineGrowthPerIteration)
t.Errorf("This indicates goroutines are NOT being cleaned up properly")
} else {
t.Logf("✓ Goroutine growth is acceptable: %.2f per iteration", growthPerIteration)
t.Logf(" (Trace creates 2 goroutines per call: pubsub.forward + stateWorker)")
t.Logf(" (These exit asynchronously after Release(), causing temporary accumulation)")
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// newMemTestContext creates a context for memory leak testing
func newMemTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "mem-test-user",
ClientID: "mem-test-client",
UserID: "mem-user-123",
TeamID: "mem-team-456",
TenantID: "mem-tenant-789",
Constraints: types.DataConstraints{
TeamOnly: true,
Extra: map[string]interface{}{
"department": "engineering",
},
},
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "MemTestAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}

View file

@ -1,117 +0,0 @@
package hook_test
import (
"sync"
"testing"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
)
// TestNestedScriptCall tests nested script calls with V8 context sharing
// This test calls: hook -> scripts.tests.create.NestedCall -> GetRoles/GetRole -> models
func TestNestedScriptCall(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Create context
ctx := newTestContext("test-nested-call", "tests.create")
// Call with deep_nested_call scenario
// This will: hook -> scripts.tests.create.NestedCall -> GetRoles -> model
res, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "deep_nested_call"},
})
if err != nil {
t.Fatalf("Nested call failed: %s", err.Error())
}
if res == nil {
t.Fatal("Expected non-nil response")
}
// Verify messages
if len(res.Messages) == 0 {
t.Fatal("Expected messages in response")
}
t.Logf("✓ Nested script call completed successfully")
t.Logf(" Messages count: %d", len(res.Messages))
if res.Metadata != nil {
t.Logf(" Metadata: %+v", res.Metadata)
}
}
// TestNestedScriptCallConcurrent tests nested script calls under high concurrency
// Simulates 100 concurrent users making nested script calls
func TestNestedScriptCallConcurrent(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// High concurrency test: 100 concurrent users (testing race condition)
concurrency := 100
iterations := 1 // Each user makes 1 call
var wg sync.WaitGroup
errors := make(chan error, concurrency*iterations)
t.Logf("Starting concurrent test: %d users × %d iterations = %d total calls",
concurrency, iterations, concurrency*iterations)
for i := 0; i < concurrency; i++ {
wg.Add(1)
go func(userID int) {
defer wg.Done()
for j := 0; j < iterations; j++ {
ctx := newTestContext("test-concurrent", "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "deep_nested_call"},
})
if err != nil {
errors <- err
return
}
}
}(i)
}
// Wait for all goroutines to complete
wg.Wait()
close(errors)
// Check for errors
errorCount := 0
for err := range errors {
errorCount++
t.Errorf("Concurrent call failed: %s", err.Error())
}
if errorCount > 0 {
t.Fatalf("Failed with %d errors out of %d total calls", errorCount, concurrency*iterations)
}
t.Logf("✓ All %d concurrent nested calls completed successfully", concurrency*iterations)
}

View file

@ -1,478 +0,0 @@
package hook_test
import (
stdContext "context"
"testing"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newTestContext creates a Context for testing with commonly used fields pre-populated.
// You can override any fields after creation as needed for specific test scenarios.
func newTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
ClientID: "test-client-id",
Scope: "openid profile email",
SessionID: "test-session-id",
UserID: "test-user-123",
TeamID: "test-team-456",
TenantID: "test-tenant-789",
RememberMe: true,
Constraints: types.DataConstraints{
OwnerOnly: false,
CreatorOnly: false,
EditorOnly: false,
TeamOnly: true,
Extra: map[string]interface{}{
"department": "engineering",
"region": "us-west",
"project": "yao",
},
},
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "TestAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}
// TestCreate test the create hook
func TestCreate(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get the tests.create assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("The tests.create assistant has no script")
}
// Use the helper function to create a test context
ctx := newTestContext("chat-test-create-hook", "tests.create")
// Test scenario 1: Return null (should get nil response)
t.Run("ReturnNull", func(t *testing.T) {
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_null"}})
if err != nil {
t.Fatalf("Failed to create with null return: %s", err.Error())
}
if res != nil {
t.Errorf("Expected nil response for null return, got: %v", res)
}
})
// Test scenario 2: Return undefined (should get nil response)
t.Run("ReturnUndefined", func(t *testing.T) {
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_undefined"}})
if err != nil {
t.Fatalf("Failed to create with undefined return: %s", err.Error())
}
if res != nil {
t.Errorf("Expected nil response for undefined return, got: %v", res)
}
})
// Test scenario 3: Return empty object (should get empty HookCreateResponse)
t.Run("ReturnEmpty", func(t *testing.T) {
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_empty"}})
if err != nil {
t.Fatalf("Failed to create with empty return: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response for empty object, got nil")
}
if len(res.Messages) != 0 {
t.Errorf("Expected empty messages, got: %d messages", len(res.Messages))
}
})
// Test scenario 4: Return full response with all fields
t.Run("ReturnFull", func(t *testing.T) {
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_full"}})
if err != nil {
t.Fatalf("Failed to create with full return: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify messages
if len(res.Messages) != 2 {
t.Errorf("Expected 2 messages, got: %d", len(res.Messages))
} else {
if res.Messages[0].Role != context.RoleSystem {
t.Errorf("Expected system role for first message, got: %s", res.Messages[0].Role)
}
if res.Messages[1].Role != context.RoleUser {
t.Errorf("Expected user role for second message, got: %s", res.Messages[1].Role)
}
}
// Verify audio config
if res.Audio == nil {
t.Error("Expected audio config, got nil")
} else {
if res.Audio.Voice != "alloy" {
t.Errorf("Expected voice 'alloy', got: %s", res.Audio.Voice)
}
if res.Audio.Format != "mp3" {
t.Errorf("Expected format 'mp3', got: %s", res.Audio.Format)
}
}
// Verify temperature
if res.Temperature == nil {
t.Error("Expected temperature, got nil")
} else if *res.Temperature != 0.7 {
t.Errorf("Expected temperature 0.7, got: %f", *res.Temperature)
}
// Verify max_tokens
if res.MaxTokens == nil {
t.Error("Expected max_tokens, got nil")
} else if *res.MaxTokens != 2000 {
t.Errorf("Expected max_tokens 2000, got: %d", *res.MaxTokens)
}
// Verify max_completion_tokens
if res.MaxCompletionTokens == nil {
t.Error("Expected max_completion_tokens, got nil")
} else if *res.MaxCompletionTokens != 1500 {
t.Errorf("Expected max_completion_tokens 1500, got: %d", *res.MaxCompletionTokens)
}
})
// Test scenario 5: Return partial response
t.Run("ReturnPartial", func(t *testing.T) {
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_partial"}})
if err != nil {
t.Fatalf("Failed to create with partial return: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify messages
if len(res.Messages) != 1 {
t.Errorf("Expected 1 message, got: %d", len(res.Messages))
}
// Verify temperature
if res.Temperature == nil {
t.Error("Expected temperature, got nil")
} else if *res.Temperature != 0.5 {
t.Errorf("Expected temperature 0.5, got: %f", *res.Temperature)
}
// Verify optional fields are nil
if res.Audio != nil {
t.Errorf("Expected audio to be nil, got: %v", res.Audio)
}
if res.MaxTokens != nil {
t.Errorf("Expected max_tokens to be nil, got: %d", *res.MaxTokens)
}
})
// Test scenario 6: Process call - calls models.__yao.role.Get and adds to messages
t.Run("ReturnProcess", func(t *testing.T) {
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "return_process"}})
if err != nil {
t.Fatalf("Failed to create with process return: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify messages - should have at least 1 (system message)
if len(res.Messages) < 1 {
t.Errorf("Expected at least 1 message, got: %d", len(res.Messages))
} else {
// First message should be system role
if res.Messages[0].Role != context.RoleSystem {
t.Errorf("Expected system role for first message, got: %s", res.Messages[0].Role)
}
// Check system message content
if content, ok := res.Messages[0].Content.(string); ok {
if content != "Here are the available roles in the system:" {
t.Errorf("Unexpected system message content: %s", content)
}
}
}
})
// Test scenario 7: Default response
t.Run("ReturnDefault", func(t *testing.T) {
testContent := "Hello, how are you?"
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: testContent}})
if err != nil {
t.Fatalf("Failed to create with default return: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify messages
if len(res.Messages) != 1 {
t.Errorf("Expected 1 message, got: %d", len(res.Messages))
} else {
if res.Messages[0].Role != context.RoleUser {
t.Errorf("Expected user role, got: %s", res.Messages[0].Role)
}
if content, ok := res.Messages[0].Content.(string); ok {
if content != testContent {
t.Errorf("Expected content '%s', got: '%s'", testContent, content)
}
} else {
t.Errorf("Expected string content, got: %T", res.Messages[0].Content)
}
}
})
// Test scenario 8: Verify context fields - validates all context fields in JavaScript
t.Run("VerifyContext", func(t *testing.T) {
res, _, err := agent.HookScript.Create(ctx, []context.Message{{Role: "user", Content: "verify_context"}})
if err != nil {
t.Fatalf("Failed to create with verify_context: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify we have messages
if len(res.Messages) < 1 {
t.Fatalf("Expected at least 1 message, got: %d", len(res.Messages))
}
// First message should be system role with success/failure indicator
if res.Messages[0].Role != context.RoleSystem {
t.Errorf("Expected system role for first message, got: %s", res.Messages[0].Role)
}
// Check the validation result
content, ok := res.Messages[0].Content.(string)
if !ok {
t.Fatalf("Expected string content for system message, got: %T", res.Messages[0].Content)
}
// The content should be "success:all_fields_validated"
if content != "success:all_fields_validated" {
t.Errorf("Context validation failed: %s", content)
// Print detailed validation results if available
if len(res.Messages) > 1 {
if details, ok := res.Messages[1].Content.(string); ok {
t.Logf("Validation details:\n%s", details)
}
}
} else {
t.Log("✓ All context fields validated successfully in JavaScript")
// Optionally print validation details
if len(res.Messages) > 1 {
if details, ok := res.Messages[1].Content.(string); ok {
t.Logf("Validation details:\n%s", details)
}
}
}
})
// Test scenario 9: Adjust context fields - tests that context fields can be modified by the hook
t.Run("AdjustContext", func(t *testing.T) {
// Create a fresh context for this test
adjustCtx := newTestContext("chat-test-adjust", "tests.create")
// Call the hook which should adjust context fields
res, _, err := agent.HookScript.Create(adjustCtx, []context.Message{{Role: "user", Content: "adjust_context"}})
if err != nil {
t.Fatalf("Failed to create with adjust_context: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify the response contains adjusted fields
// Note: AssistantID cannot be overridden by hooks, removed from HookCreateResponse
if res.Connector != "adjusted-connector" {
t.Errorf("Expected adjusted connector 'adjusted-connector', got: %s", res.Connector)
}
if res.Locale != "zh-cn" {
t.Errorf("Expected adjusted locale 'zh-cn', got: %s", res.Locale)
}
if res.Theme != "dark" {
t.Errorf("Expected adjusted theme 'dark', got: %s", res.Theme)
}
if res.Route != "/adjusted/route" {
t.Errorf("Expected adjusted route '/adjusted/route', got: %s", res.Route)
}
// Verify metadata
if res.Metadata == nil {
t.Fatalf("Expected metadata, got nil")
}
if adjusted, ok := res.Metadata["adjusted"].(bool); !ok || !adjusted {
t.Errorf("Expected metadata['adjusted'] = true, got: %v", res.Metadata["adjusted"])
}
// Verify context fields were actually updated
// Note: AssistantID is immutable and cannot be overridden
// Note: Connector is now in Options, not in Context
if adjustCtx.Locale != "zh-cn" {
t.Errorf("Context locale not updated. Expected 'zh-cn', got: %s", adjustCtx.Locale)
}
if adjustCtx.Theme != "dark" {
t.Errorf("Context theme not updated. Expected 'dark', got: %s", adjustCtx.Theme)
}
if adjustCtx.Route != "/adjusted/route" {
t.Errorf("Context route not updated. Expected '/adjusted/route', got: %s", adjustCtx.Route)
}
if adjustCtx.Metadata["adjusted"] != true {
t.Errorf("Context metadata not updated. Expected metadata['adjusted'] = true, got: %v", adjustCtx.Metadata["adjusted"])
}
t.Log("✓ Context fields successfully adjusted by hook")
})
// Test scenario 10: Adjust uses configuration - tests that uses can be modified by the hook
t.Run("AdjustUses", func(t *testing.T) {
// Create a fresh context for this test
usesCtx := newTestContext("chat-test-uses", "tests.create")
// Call the hook which should adjust uses configuration
res, _, err := agent.HookScript.Create(usesCtx, []context.Message{{Role: "user", Content: "adjust_uses"}})
if err != nil {
t.Fatalf("Failed to create with adjust_uses: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify the response contains uses configuration
if res.Uses == nil {
t.Fatalf("Expected uses configuration, got nil")
}
// Verify each uses field
if res.Uses.Vision != "mcp:vision-server" {
t.Errorf("Expected vision 'mcp:vision-server', got: %s", res.Uses.Vision)
}
if res.Uses.Audio != "mcp:audio-server" {
t.Errorf("Expected audio 'mcp:audio-server', got: %s", res.Uses.Audio)
}
if res.Uses.Search != "agent" {
t.Errorf("Expected search 'agent', got: %s", res.Uses.Search)
}
if res.Uses.Fetch != "mcp:fetch-server" {
t.Errorf("Expected fetch 'mcp:fetch-server', got: %s", res.Uses.Fetch)
}
// Verify metadata
if res.Metadata == nil {
t.Fatalf("Expected metadata, got nil")
}
if usesAdjusted, ok := res.Metadata["uses_adjusted"].(bool); !ok || !usesAdjusted {
t.Errorf("Expected metadata['uses_adjusted'] = true, got: %v", res.Metadata["uses_adjusted"])
}
// Now test that BuildRequest properly applies the uses configuration
inputMessages := []context.Message{{Role: "user", Content: "test uses"}}
_, options, err := agent.BuildRequest(usesCtx, inputMessages, res)
if err != nil {
t.Fatalf("Failed to build request: %s", err.Error())
}
// Verify that options.Uses has the values from createResponse
if options.Uses == nil {
t.Fatalf("Expected options.Uses to be set, got nil")
}
if options.Uses.Vision != "mcp:vision-server" {
t.Errorf("Expected options.Uses.Vision 'mcp:vision-server', got: %s", options.Uses.Vision)
}
if options.Uses.Audio != "mcp:audio-server" {
t.Errorf("Expected options.Uses.Audio 'mcp:audio-server', got: %s", options.Uses.Audio)
}
if options.Uses.Search != "agent" {
t.Errorf("Expected options.Uses.Search 'agent', got: %s", options.Uses.Search)
}
if options.Uses.Fetch != "mcp:fetch-server" {
t.Errorf("Expected options.Uses.Fetch 'mcp:fetch-server', got: %s", options.Uses.Fetch)
}
t.Log("✓ Uses configuration successfully adjusted by hook and applied to options")
})
// Test scenario 11: Adjust uses configuration with force_uses flag
t.Run("AdjustUsesForce", func(t *testing.T) {
// Create a fresh context for this test
usesCtx := newTestContext("chat-test-uses-force", "tests.create")
// Call the hook which should adjust uses configuration and set force_uses
res, _, err := agent.HookScript.Create(usesCtx, []context.Message{{Role: "user", Content: "adjust_uses_force"}})
if err != nil {
t.Fatalf("Failed to create with adjust_uses_force: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify the response contains uses configuration
if res.Uses == nil {
t.Fatalf("Expected uses configuration, got nil")
}
// Verify uses fields
if res.Uses.Vision != "tests.vision-helper" {
t.Errorf("Expected vision 'tests.vision-helper', got: %s", res.Uses.Vision)
}
if res.Uses.Audio != "mcp:audio-server" {
t.Errorf("Expected audio 'mcp:audio-server', got: %s", res.Uses.Audio)
}
// Verify force_uses flag
if res.ForceUses == nil {
t.Fatalf("Expected force_uses to be set, got nil")
}
if !*res.ForceUses {
t.Errorf("Expected force_uses to be true, got: %v", *res.ForceUses)
}
// Verify metadata
if res.Metadata == nil {
t.Fatalf("Expected metadata, got nil")
}
if usesForced, ok := res.Metadata["uses_forced"].(bool); !ok || !usesForced {
t.Errorf("Expected metadata['uses_forced'] = true, got: %v", res.Metadata["uses_forced"])
}
// Now test that BuildRequest properly applies the force_uses flag
inputMessages := []context.Message{{Role: "user", Content: "test force uses"}}
_, options, err := agent.BuildRequest(usesCtx, inputMessages, res)
if err != nil {
t.Fatalf("Failed to build request: %s", err.Error())
}
// Verify that options.ForceUses is true
if !options.ForceUses {
t.Errorf("Expected options.ForceUses to be true, got: %v", options.ForceUses)
}
t.Log("✓ Uses configuration with force_uses flag successfully adjusted by hook and applied to options")
})
}

View file

@ -1,324 +0,0 @@
package hook_test
import (
stdContext "context"
"fmt"
"os"
"runtime"
"runtime/pprof"
"strings"
"testing"
"time"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// TestGoroutineLeakDetailed performs detailed goroutine leak analysis
func TestGoroutineLeakDetailed(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("Assistant has no script")
}
// Create profile directory
os.MkdirAll("/tmp/goroutine_profiles", 0755)
// Take initial snapshot
runtime.GC()
time.Sleep(200 * time.Millisecond)
initialGoroutines := runtime.NumGoroutine()
// Save initial profile
saveGoroutineProfile("/tmp/goroutine_profiles/00_initial.txt")
t.Logf("Initial goroutines: %d", initialGoroutines)
// Test with just 10 iterations to see the pattern
iterations := 10
for i := 0; i < iterations; i++ {
ctx := newLeakTestContext(fmt.Sprintf("leak-test-%d", i), "tests.create")
_, _, err := agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
if err != nil {
t.Errorf("Create failed at iteration %d: %s", i, err.Error())
}
// Release context
ctx.Release()
// Check goroutines after each iteration
current := runtime.NumGoroutine()
growth := current - initialGoroutines
t.Logf("After iteration %d: %d goroutines (growth: %d)", i+1, current, growth)
// Save profile every 5 iterations
if (i+1)%5 == 0 {
saveGoroutineProfile(fmt.Sprintf("/tmp/goroutine_profiles/%02d_after_iter_%d.txt", i+1, i+1))
}
}
// Force cleanup
runtime.GC()
time.Sleep(500 * time.Millisecond)
finalGoroutines := runtime.NumGoroutine()
growth := finalGoroutines - initialGoroutines
t.Logf("\n=== SUMMARY ===")
t.Logf("Initial: %d goroutines", initialGoroutines)
t.Logf("Final: %d goroutines", finalGoroutines)
t.Logf("Growth: %d goroutines (%.2f per iteration)", growth, float64(growth)/float64(iterations))
// Save final profile
saveGoroutineProfile("/tmp/goroutine_profiles/99_final.txt")
// Analyze the leak
t.Logf("\n=== ANALYSIS ===")
analyzeGoroutineProfiles(t, "/tmp/goroutine_profiles")
}
// TestGoroutineLeakByComponent tests each component separately
func TestGoroutineLeakByComponent(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
os.MkdirAll("/tmp/component_profiles", 0755)
t.Run("ContextCreationOnly", func(t *testing.T) {
runtime.GC()
time.Sleep(100 * time.Millisecond)
initial := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
ctx := newLeakTestContext(fmt.Sprintf("test-%d", i), "tests.create")
_ = ctx
ctx.Release()
}
runtime.GC()
time.Sleep(100 * time.Millisecond)
final := runtime.NumGoroutine()
t.Logf("Context creation: initial=%d, final=%d, growth=%d", initial, final, final-initial)
})
t.Run("ScriptExecutionOnly", func(t *testing.T) {
runtime.GC()
time.Sleep(100 * time.Millisecond)
initial := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
ctx := newLeakTestContext(fmt.Sprintf("test-%d", i), "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
ctx.Release()
}
runtime.GC()
time.Sleep(100 * time.Millisecond)
final := runtime.NumGoroutine()
t.Logf("Script execution: initial=%d, final=%d, growth=%d", initial, final, final-initial)
saveGoroutineProfile("/tmp/component_profiles/script_execution.txt")
})
t.Run("TraceOperations", func(t *testing.T) {
runtime.GC()
time.Sleep(100 * time.Millisecond)
initial := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
ctx := newLeakTestContext(fmt.Sprintf("test-%d", i), "tests.create")
// Create trace
trace, err := ctx.Trace()
if err == nil && trace != nil {
// Trace operations
_ = trace
}
ctx.Release()
}
runtime.GC()
time.Sleep(100 * time.Millisecond)
final := runtime.NumGoroutine()
t.Logf("Trace operations: initial=%d, final=%d, growth=%d", initial, final, final-initial)
saveGoroutineProfile("/tmp/component_profiles/trace_operations.txt")
})
}
// TestGoroutineLeakWithoutRelease tests if Release() fixes the leak
func TestGoroutineLeakWithoutRelease(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.create")
if err != nil {
t.Fatalf("Failed to get assistant: %s", err.Error())
}
t.Run("WithoutRelease", func(t *testing.T) {
runtime.GC()
time.Sleep(100 * time.Millisecond)
initial := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
ctx := newLeakTestContext(fmt.Sprintf("no-release-%d", i), "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
// Intentionally NOT calling ctx.Release()
}
runtime.GC()
time.Sleep(100 * time.Millisecond)
final := runtime.NumGoroutine()
t.Logf("WITHOUT Release: initial=%d, final=%d, growth=%d (%.1f per iter)",
initial, final, final-initial, float64(final-initial)/10.0)
})
t.Run("WithRelease", func(t *testing.T) {
runtime.GC()
time.Sleep(100 * time.Millisecond)
initial := runtime.NumGoroutine()
for i := 0; i < 10; i++ {
ctx := newLeakTestContext(fmt.Sprintf("with-release-%d", i), "tests.create")
_, _, _ = agent.HookScript.Create(ctx, []context.Message{
{Role: "user", Content: "Hello"},
})
ctx.Release() // WITH Release
}
runtime.GC()
time.Sleep(100 * time.Millisecond)
final := runtime.NumGoroutine()
t.Logf("WITH Release: initial=%d, final=%d, growth=%d (%.1f per iter)",
initial, final, final-initial, float64(final-initial)/10.0)
})
}
// Helper functions
func saveGoroutineProfile(filename string) {
f, err := os.Create(filename)
if err != nil {
return
}
defer f.Close()
pprof.Lookup("goroutine").WriteTo(f, 2) // detail level 2
}
func analyzeGoroutineProfiles(t *testing.T, dir string) {
// Read initial and final profiles
initialData, err := os.ReadFile(dir + "/00_initial.txt")
if err != nil {
t.Logf("Could not read initial profile: %v", err)
return
}
finalData, err := os.ReadFile(dir + "/99_final.txt")
if err != nil {
t.Logf("Could not read final profile: %v", err)
return
}
// Count goroutines by function
initialFuncs := countGoroutinesByFunction(string(initialData))
finalFuncs := countGoroutinesByFunction(string(finalData))
t.Logf("\nGoroutine growth by function:")
t.Logf("%-60s %8s %8s %8s", "Function", "Initial", "Final", "Growth")
t.Logf("%s", strings.Repeat("-", 90))
// Find functions that grew
for fn, finalCount := range finalFuncs {
initialCount := initialFuncs[fn]
growth := finalCount - initialCount
if growth > 0 {
t.Logf("%-60s %8d %8d %8d", truncate(fn, 60), initialCount, finalCount, growth)
}
}
t.Logf("\nProfiles saved to: %s", dir)
t.Logf("To compare: diff %s/00_initial.txt %s/99_final.txt | grep '^>'", dir, dir)
}
func countGoroutinesByFunction(profile string) map[string]int {
counts := make(map[string]int)
lines := strings.Split(profile, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
// Look for function names in goroutine stack traces
if strings.Contains(line, "(") && !strings.HasPrefix(line, "#") {
// Extract function name
if idx := strings.Index(line, "("); idx > 0 {
fn := strings.TrimSpace(line[:idx])
counts[fn]++
}
}
}
return counts
}
func truncate(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}
func newLeakTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "leak-test-user",
ClientID: "leak-test-client",
UserID: "leak-user-123",
TeamID: "leak-team-456",
TenantID: "leak-tenant-789",
Constraints: types.DataConstraints{
TeamOnly: true,
Extra: map[string]interface{}{
"department": "testing",
},
},
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "LeakTestAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}

View file

@ -1 +0,0 @@
package hook

View file

@ -1,69 +0,0 @@
package hook
import (
"encoding/json"
"fmt"
"github.com/yaoapp/gou/runtime/v8/bridge"
"github.com/yaoapp/yao/agent/context"
)
// Next next hook for the next action after the completion
// opts is optional - if provided, will be passed to the hook
func (s *Script) Next(ctx *context.Context, payload *context.NextHookPayload, opts ...*context.Options) (*context.NextHookResponse, *context.Options, error) {
// Get or create options
var options *context.Options
if len(opts) > 0 && opts[0] != nil {
options = opts[0]
} else {
options = &context.Options{}
}
// Convert payload to map for JS (use JSON tag names)
payloadMap := map[string]interface{}{
"messages": payload.Messages,
"completion": payload.Completion,
"tools": payload.Tools,
"error": payload.Error,
}
// Execute hook with ctx, payload, and options (convert options to map for JS)
optionsMap := options.ToMap()
res, err := s.Execute(ctx, "Next", payloadMap, optionsMap)
if err != nil {
return nil, nil, err
}
response, err := s.getNextHookResponse(res)
if err != nil {
return nil, nil, err
}
return response, options, nil
}
// getNextHookResponse convert the result to a NextHookResponse
func (s *Script) getNextHookResponse(res interface{}) (*context.NextHookResponse, error) {
// Handle nil result
if res == nil {
return nil, nil
}
// Handle undefined result (treat as nil)
if _, ok := res.(bridge.UndefinedT); ok {
return nil, nil
}
// Marshal to JSON and unmarshal to NextHookResponse
raw, err := json.Marshal(res)
if err != nil {
return nil, fmt.Errorf("failed to marshal Next hook result: %w", err)
}
var response context.NextHookResponse
if err := json.Unmarshal(raw, &response); err != nil {
return nil, fmt.Errorf("failed to unmarshal to NextHookResponse: %w", err)
}
return &response, nil
}

View file

@ -1,436 +0,0 @@
package hook_test
import (
stdContext "context"
"testing"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newTestContextForNext creates a Context for testing Next Hook with commonly used fields pre-populated.
// You can override any fields after creation as needed for specific test scenarios.
func newTestContextForNext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
ClientID: "test-client-id",
Scope: "openid profile email",
SessionID: "test-session-id",
UserID: "test-user-123",
TeamID: "test-team-456",
TenantID: "test-tenant-789",
RememberMe: true,
Constraints: types.DataConstraints{
OwnerOnly: false,
CreatorOnly: false,
EditorOnly: false,
TeamOnly: true,
Extra: map[string]interface{}{
"department": "engineering",
"region": "us-west",
"project": "yao",
},
},
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "TestAgent/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}
// TestNext tests the Next hook
func TestNext(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.next")
if err != nil {
t.Fatalf("Failed to get the tests.next assistant: %s", err.Error())
}
if agent.HookScript == nil {
t.Fatalf("The tests.next assistant has no script")
}
// Use the helper function to create a test context
ctx := newTestContextForNext("chat-test-next-hook", "tests.next")
// Test scenario 1: Return null (should get nil response)
t.Run("ReturnNull", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "return_null"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
Tools: nil,
Error: "",
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook with null return: %s", err.Error())
}
if res != nil {
t.Errorf("Expected nil response for null return, got: %v", res)
}
})
// Test scenario 2: Return undefined (should get nil response)
t.Run("ReturnUndefined", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "return_undefined"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook with undefined return: %s", err.Error())
}
if res != nil {
t.Errorf("Expected nil response for undefined return, got: %v", res)
}
})
// Test scenario 3: Return empty object (should get empty NextHookResponse)
t.Run("ReturnEmpty", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "return_empty"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook with empty return: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response for empty object, got nil")
}
if res.Delegate != nil {
t.Errorf("Expected nil Delegate, got: %v", res.Delegate)
}
if res.Data != nil {
t.Errorf("Expected nil Data, got: %v", res.Data)
}
})
// Test scenario 4: Return custom data
t.Run("ReturnCustomData", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "return_custom_data"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook with custom data: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify Data is present
if res.Data == nil {
t.Fatalf("Expected Data to be present, got nil")
}
// Data should be a map
dataMap, ok := res.Data.(map[string]interface{})
if !ok {
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
}
// Verify custom data fields
if message, ok := dataMap["message"].(string); !ok || message != "Custom response from Next Hook" {
t.Errorf("Expected custom message, got: %v", dataMap["message"])
}
if test, ok := dataMap["test"].(bool); !ok || !test {
t.Errorf("Expected test=true, got: %v", dataMap["test"])
}
if _, ok := dataMap["timestamp"]; !ok {
t.Errorf("Expected timestamp field")
}
// Verify Delegate is nil
if res.Delegate != nil {
t.Errorf("Expected nil Delegate, got: %v", res.Delegate)
}
})
// Test scenario 5: Return data with metadata
t.Run("ReturnDataWithMetadata", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "return_data_with_metadata"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify Data
if res.Data == nil {
t.Fatalf("Expected Data to be present, got nil")
}
dataMap, ok := res.Data.(map[string]interface{})
if !ok {
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
}
if result, ok := dataMap["result"].(string); !ok || result != "success" {
t.Errorf("Expected result='success', got: %v", dataMap["result"])
}
// Verify Metadata
if res.Metadata == nil {
t.Fatalf("Expected Metadata to be present, got nil")
}
if hook, ok := res.Metadata["hook"].(string); !ok || hook != "next" {
t.Errorf("Expected hook='next', got: %v", res.Metadata["hook"])
}
if processed, ok := res.Metadata["processed"].(bool); !ok || !processed {
t.Errorf("Expected processed=true, got: %v", res.Metadata["processed"])
}
})
// Test scenario 6: Return delegate
t.Run("ReturnDelegate", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "return_delegate"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook with delegate: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify Delegate is present
if res.Delegate == nil {
t.Fatalf("Expected Delegate to be present, got nil")
}
// Verify delegate fields
if res.Delegate.AgentID != "tests.create" {
t.Errorf("Expected AgentID='tests.create', got: %s", res.Delegate.AgentID)
}
if len(res.Delegate.Messages) != 1 {
t.Errorf("Expected 1 message, got: %d", len(res.Delegate.Messages))
} else {
if res.Delegate.Messages[0].Role != context.RoleUser {
t.Errorf("Expected user role, got: %s", res.Delegate.Messages[0].Role)
}
if content, ok := res.Delegate.Messages[0].Content.(string); !ok || content != "Hello from delegated agent" {
t.Errorf("Expected specific content, got: %v", res.Delegate.Messages[0].Content)
}
}
// Verify Data is nil (only delegate, no custom data)
if res.Data != nil {
t.Logf("Note: Data is present alongside Delegate: %v", res.Data)
}
})
// Test scenario 7: Verify payload structure
t.Run("VerifyPayload", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleSystem, Content: "System message"},
{Role: context.RoleUser, Content: "verify_payload"},
},
Completion: &context.CompletionResponse{
Content: "Test completion content",
Usage: &message.UsageInfo{
PromptTokens: 10,
CompletionTokens: 20,
TotalTokens: 30,
},
},
Tools: []context.ToolCallResponse{
{
ToolCallID: "call_123",
Server: "test-server",
Tool: "test-tool",
Result: map[string]interface{}{"success": true},
Error: "",
},
},
Error: "",
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify Data contains validation results
if res.Data == nil {
t.Fatalf("Expected Data with validation results, got nil")
}
dataMap, ok := res.Data.(map[string]interface{})
if !ok {
t.Fatalf("Expected Data to be map[string]interface{}, got: %T", res.Data)
}
if validation, ok := dataMap["validation"].(string); !ok || validation != "success" {
t.Errorf("Expected validation='success', got: %v", dataMap["validation"])
}
if checks, ok := dataMap["checks"].([]interface{}); !ok {
t.Errorf("Expected checks array, got: %T", dataMap["checks"])
} else {
t.Logf("✓ Payload validation checks: %d items", len(checks))
for i, check := range checks {
t.Logf(" [%d] %v", i, check)
}
}
})
// Test scenario 8: Verify tools processing
t.Run("VerifyTools", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "verify_tools"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
Tools: []context.ToolCallResponse{
{
ToolCallID: "call_1",
Server: "server1",
Tool: "tool1",
Result: map[string]interface{}{"value": 42},
Error: "",
},
{
ToolCallID: "call_2",
Server: "server2",
Tool: "tool2",
Result: nil,
Error: "Tool execution failed",
},
},
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify Data
if res.Data == nil {
t.Fatalf("Expected Data, got nil")
}
dataMap, ok := res.Data.(map[string]interface{})
if !ok {
t.Fatalf("Expected Data to be map, got: %T", res.Data)
}
// Verify tool statistics
if totalTools, ok := dataMap["total_tools"].(float64); !ok || int(totalTools) != 2 {
t.Errorf("Expected total_tools=2, got: %v", dataMap["total_tools"])
}
if successful, ok := dataMap["successful"].(float64); !ok || int(successful) != 1 {
t.Errorf("Expected successful=1, got: %v", dataMap["successful"])
}
if failed, ok := dataMap["failed"].(float64); !ok || int(failed) != 1 {
t.Errorf("Expected failed=1, got: %v", dataMap["failed"])
}
t.Log("✓ Tools processing validated successfully")
})
// Test scenario 9: Handle error
t.Run("HandleError", func(t *testing.T) {
payload := &context.NextHookPayload{
Messages: []context.Message{
{Role: context.RoleUser, Content: "handle_error"},
},
Completion: &context.CompletionResponse{
Content: "Test completion",
},
Error: "Tool execution failed: timeout",
}
res, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Failed to execute Next hook: %s", err.Error())
}
if res == nil {
t.Fatalf("Expected non-nil response, got nil")
}
// Verify error handling
if res.Data == nil {
t.Fatalf("Expected Data, got nil")
}
dataMap, ok := res.Data.(map[string]interface{})
if !ok {
t.Fatalf("Expected Data to be map, got: %T", res.Data)
}
if errorMsg, ok := dataMap["error"].(string); !ok || errorMsg != "Tool execution failed: timeout" {
t.Errorf("Expected error message, got: %v", dataMap["error"])
}
if recovered, ok := dataMap["recovered"].(bool); !ok || !recovered {
t.Errorf("Expected recovered=true, got: %v", dataMap["recovered"])
}
t.Log("✓ Error handling validated successfully")
})
}

View file

@ -1,414 +0,0 @@
package hook_test
import (
stdContext "context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newRealWorldNextContext creates a Context for real world Next Hook testing
func newRealWorldNextContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "realworld-test-user",
ClientID: "realworld-test-client",
Scope: "openid profile",
SessionID: "realworld-test-session",
UserID: "realworld-user-123",
TeamID: "realworld-team-456",
TenantID: "realworld-tenant-789",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "RealWorldTest/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}
// TestRealWorldNextStandard tests standard response (nil return)
func TestRealWorldNextStandard(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world Next Hook test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldNextContext("test-next-standard", "tests.realworld-next")
// Simulate completion with scenario marker
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: standard"},
{Role: context.RoleAssistant, Content: "I'll process your request using standard response."},
}
completion := &context.CompletionResponse{
Content: "Processing complete. Standard response will be used.",
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: nil,
Error: "",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
// Should return nil for standard response
assert.Nil(t, response, "Standard scenario should return nil")
t.Log("✓ Standard response scenario passed")
}
// TestRealWorldNextCustomData tests custom data response
func TestRealWorldNextCustomData(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world Next Hook test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldNextContext("test-next-custom", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: custom_data"},
{Role: context.RoleAssistant, Content: "Here's some information for you."},
}
completion := &context.CompletionResponse{
Content: "This is the LLM completion that will be summarized.",
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: nil,
Error: "",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
assert.NotNil(t, response, "Custom data scenario should return response")
assert.NotNil(t, response.Data, "Response should have Data")
dataMap, ok := response.Data.(map[string]interface{})
assert.True(t, ok, "Data should be a map")
assert.Equal(t, "custom_response", dataMap["type"])
assert.Contains(t, dataMap, "timestamp")
t.Log("✓ Custom data response scenario passed")
}
// TestRealWorldNextDelegate tests agent delegation
func TestRealWorldNextDelegate(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world Next Hook test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldNextContext("test-next-delegate", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: delegate"},
}
completion := &context.CompletionResponse{
Content: "I should delegate this request to another agent.",
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: nil,
Error: "",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
assert.NotNil(t, response, "Delegate scenario should return response")
assert.NotNil(t, response.Delegate, "Response should have Delegate")
assert.Equal(t, "tests.create", response.Delegate.AgentID)
assert.NotEmpty(t, response.Delegate.Messages)
t.Log("✓ Delegation scenario passed")
}
// TestRealWorldNextProcessTools tests tool result processing
func TestRealWorldNextProcessTools(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world Next Hook test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldNextContext("test-next-tools", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: process_tools"},
}
completion := &context.CompletionResponse{
Content: "Tool calls have been executed.",
}
// Simulate tool call results
tools := []context.ToolCallResponse{
{
ToolCallID: "call_1",
Server: "test-server",
Tool: "test-tool-1",
Result: map[string]interface{}{"status": "success"},
Error: "",
},
{
ToolCallID: "call_2",
Server: "test-server",
Tool: "test-tool-2",
Result: nil,
Error: "Tool execution failed",
},
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: tools,
Error: "",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
assert.NotNil(t, response, "Process tools scenario should return response")
assert.NotNil(t, response.Data, "Response should have Data")
dataMap, ok := response.Data.(map[string]interface{})
assert.True(t, ok, "Data should be a map")
assert.Equal(t, "Tool execution summary", dataMap["message"])
// Check summary
summary, ok := dataMap["summary"].(map[string]interface{})
assert.True(t, ok, "Should have summary")
assert.Equal(t, float64(2), summary["total"])
assert.Equal(t, float64(1), summary["successful"])
assert.Equal(t, float64(1), summary["failed"])
t.Log("✓ Process tools scenario passed")
}
// TestRealWorldNextErrorRecovery tests error handling and recovery
func TestRealWorldNextErrorRecovery(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world Next Hook test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldNextContext("test-next-error", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: error_recovery"},
}
completion := &context.CompletionResponse{
Content: "An error occurred during processing.",
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: nil,
Error: "System error: Database connection timeout",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
assert.NotNil(t, response, "Error recovery scenario should return response")
assert.NotNil(t, response.Data, "Response should have Data")
dataMap, ok := response.Data.(map[string]interface{})
assert.True(t, ok, "Data should be a map")
assert.Equal(t, "Error was handled by Next Hook", dataMap["message"])
assert.Contains(t, dataMap, "error")
assert.Contains(t, dataMap, "recovery_action")
t.Log("✓ Error recovery scenario passed")
}
// TestRealWorldNextConditional tests conditional logic based on completion
func TestRealWorldNextConditional(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world Next Hook test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldNextContext("test-next-conditional", "tests.realworld-next")
t.Run("ConditionalSuccess", func(t *testing.T) {
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: conditional"},
}
completion := &context.CompletionResponse{
Content: "The operation completed successfully. All tasks are done.",
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: nil,
Error: "",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
assert.NotNil(t, response, "Conditional scenario should return response")
assert.NotNil(t, response.Data, "Response should have Data")
dataMap, ok := response.Data.(map[string]interface{})
assert.True(t, ok, "Data should be a map")
assert.Equal(t, "Conditional analysis complete", dataMap["message"])
assert.Contains(t, dataMap, "action")
assert.Contains(t, dataMap, "conditions")
t.Log("✓ Conditional (success) scenario passed")
})
t.Run("ConditionalDelegate", func(t *testing.T) {
messages := []context.Message{
{Role: context.RoleUser, Content: "scenario: conditional"},
}
completion := &context.CompletionResponse{
Content: "I should delegate this request to another service for better handling.",
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: nil,
Error: "",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
assert.NotNil(t, response, "Conditional delegate should return response")
assert.NotNil(t, response.Delegate, "Should delegate based on condition")
assert.Equal(t, "tests.create", response.Delegate.AgentID)
t.Log("✓ Conditional (delegate) scenario passed")
})
}
// TestRealWorldNextDefault tests default behavior
func TestRealWorldNextDefault(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world Next Hook test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld-next")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldNextContext("test-next-default", "tests.realworld-next")
messages := []context.Message{
{Role: context.RoleUser, Content: "Just a normal request"},
}
completion := &context.CompletionResponse{
Content: "Here's the response to your request.",
}
payload := &context.NextHookPayload{
Messages: messages,
Completion: completion,
Tools: nil,
Error: "",
}
response, _, err := agent.HookScript.Next(ctx, payload)
if err != nil {
t.Fatalf("Next hook failed: %v", err)
}
// Default behavior should return nil
assert.Nil(t, response, "Default scenario should return nil for standard response")
t.Log("✓ Default scenario passed")
}

View file

@ -1,767 +0,0 @@
package hook_test
import (
stdContext "context"
"fmt"
"runtime"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// ============================================================================
// Real World Stress Tests
// These tests simulate actual production usage patterns with Stream() flow
// ============================================================================
// TestRealWorldSimpleScenario tests basic Stream() flow with simple Create hook
func TestRealWorldSimpleScenario(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldContext("test-simple", "tests.realworld")
// Test Create hook with simple scenario
messages := []context.Message{
{Role: "user", Content: "simple"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
assert.NotNil(t, response)
assert.NotEmpty(t, response.Messages)
assert.Equal(t, "simple", response.Metadata["scenario"])
}
// TestRealWorldMCPScenarios tests MCP integration scenarios
func TestRealWorldMCPScenarios(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
t.Run("MCP Health", func(t *testing.T) {
ctx := newRealWorldContext("test-mcp-health", "tests.realworld")
messages := []context.Message{
{Role: "user", Content: "mcp_health"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
// Detailed validation
assert.NotNil(t, response)
assert.NotEmpty(t, response.Messages)
// Check if metadata exists
if response.Metadata == nil {
t.Logf("⚠ Metadata is nil - checking messages content")
// Verify messages contain expected content
messageContent := ""
for _, msg := range response.Messages {
if content, ok := msg.Content.(string); ok {
messageContent += content + "\n"
}
}
assert.Contains(t, messageContent, "Health", "Message should mention health")
assert.Contains(t, messageContent, "Tools", "Message should mention tools")
t.Logf("✓ MCP Health executed (verified via message content)")
} else {
assert.Equal(t, "mcp_health", response.Metadata["scenario"])
// Verify metadata contains MCP results
if toolsCount, ok := response.Metadata["tools_count"]; ok {
count := int(toolsCount.(float64))
assert.Greater(t, count, 0, "Should have tools from MCP")
t.Logf("✓ MCP Health: %d tools, health data: %v",
count, response.Metadata["health_data"])
}
}
ctx.Release()
})
t.Run("MCP Tools", func(t *testing.T) {
ctx := newRealWorldContext("test-mcp-tools", "tests.realworld")
messages := []context.Message{
{Role: "user", Content: "mcp_tools"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
// Detailed validation
assert.NotNil(t, response)
assert.NotEmpty(t, response.Messages)
// Check if metadata exists
if response.Metadata == nil {
t.Logf("⚠ Metadata is nil - checking messages content")
// Verify messages contain expected content
messageContent := ""
for _, msg := range response.Messages {
if content, ok := msg.Content.(string); ok {
messageContent += content + "\n"
}
}
assert.Contains(t, messageContent, "Tools", "Message should mention tools")
assert.Contains(t, messageContent, "Ping", "Message should mention ping")
t.Logf("✓ MCP Tools executed (verified via message content)")
} else {
assert.Equal(t, "mcp_tools", response.Metadata["scenario"])
// Verify tools were called
if toolsCount, ok := response.Metadata["tools_count"]; ok {
count := int(toolsCount.(float64))
assert.Greater(t, count, 0, "Should have tools from MCP")
// Verify operations list
if operations, ok := response.Metadata["operations"].([]interface{}); ok {
assert.Len(t, operations, 2, "Should execute 2 operations: ping, status")
t.Logf("✓ MCP Tools: %d tools, operations: %v", count, operations)
}
}
}
ctx.Release()
})
t.Run("Full Workflow", func(t *testing.T) {
ctx := newRealWorldContext("test-full-workflow", "tests.realworld")
// Initialize stack for trace
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
defer done()
ctx.Stack = stack
messages := []context.Message{
{Role: "user", Content: "full_workflow"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
// Detailed validation
assert.NotNil(t, response)
assert.NotEmpty(t, response.Messages)
// Check if metadata exists
if response.Metadata == nil {
t.Logf("⚠ Metadata is nil - checking messages content")
// Verify messages contain expected content
messageContent := ""
for _, msg := range response.Messages {
if content, ok := msg.Content.(string); ok {
messageContent += content + "\n"
}
}
assert.Contains(t, messageContent, "Workflow", "Message should mention workflow")
assert.Contains(t, messageContent, "Tools", "Message should mention tools")
assert.Contains(t, messageContent, "Roles", "Message should mention database roles")
t.Logf("✓ Full Workflow executed (verified via message content)")
} else {
assert.Equal(t, "full_workflow", response.Metadata["scenario"])
// Verify all phases completed
if phasesCompleted, ok := response.Metadata["phases_completed"]; ok {
phases := int(phasesCompleted.(float64))
assert.Equal(t, 4, phases, "Should complete 4 phases")
// Verify MCP tools
if mcpTools, ok := response.Metadata["mcp_tools"]; ok {
tools := int(mcpTools.(float64))
assert.Greater(t, tools, 0, "Should have MCP tools")
// Verify DB records
if dbRecords, ok := response.Metadata["db_records"]; ok {
records := int(dbRecords.(float64))
assert.GreaterOrEqual(t, records, 0, "Should have DB query result")
t.Logf("✓ Full Workflow: %d phases, %d MCP tools, %d DB records",
phases, tools, records)
}
}
}
}
ctx.Release()
})
}
// TestRealWorldTraceIntensive tests trace-heavy scenarios
func TestRealWorldTraceIntensive(t *testing.T) {
if testing.Short() {
t.Skip("Skipping real world test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
ctx := newRealWorldContext("test-trace-intensive", "tests.realworld")
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
defer done()
ctx.Stack = stack
messages := []context.Message{
{Role: "user", Content: "trace_intensive"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Create failed: %v", err)
}
assert.NotNil(t, response)
assert.Equal(t, "trace_intensive", response.Metadata["scenario"])
assert.NotZero(t, response.Metadata["nodes_created"])
}
// TestRealWorldStressSimple tests simple scenario under stress
func TestRealWorldStressSimple(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
iterations := 100
startMemory := getMemStats()
for i := 0; i < iterations; i++ {
ctx := newRealWorldContext(fmt.Sprintf("stress-simple-%d", i), "tests.realworld")
messages := []context.Message{
{Role: "user", Content: "simple"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Iteration %d failed: %v", i, err)
}
// Validate response
assert.NotNil(t, response, "Iteration %d: response should not be nil", i)
assert.NotEmpty(t, response.Messages, "Iteration %d: messages should not be empty", i)
if response.Metadata != nil {
assert.Equal(t, "simple", response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
}
// Explicit cleanup
ctx.Release()
if i%20 == 0 {
runtime.GC()
currentMemory := getMemStats()
t.Logf("Iteration %d: Memory: %d MB", i, currentMemory/1024/1024)
}
}
runtime.GC()
endMemory := getMemStats()
t.Logf("Simple stress: %d iterations", iterations)
t.Logf("Start memory: %d MB", startMemory/1024/1024)
t.Logf("End memory: %d MB", endMemory/1024/1024)
if endMemory > startMemory {
t.Logf("Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
} else {
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
}
}
// TestRealWorldStressMCP tests MCP scenarios under stress
func TestRealWorldStressMCP(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
iterations := 50
scenarios := []string{"mcp_health", "mcp_tools"}
startMemory := getMemStats()
for i := 0; i < iterations; i++ {
scenario := scenarios[i%len(scenarios)]
ctx := newRealWorldContext(fmt.Sprintf("stress-mcp-%d", i), "tests.realworld")
// Initialize stack for trace
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
ctx.Stack = stack
messages := []context.Message{
{Role: "user", Content: scenario},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Iteration %d (%s) failed: %v", i, scenario, err)
}
// Validate response
assert.NotNil(t, response, "Iteration %d (%s): response should not be nil", i, scenario)
assert.NotEmpty(t, response.Messages, "Iteration %d (%s): messages should not be empty", i, scenario)
// Validate metadata
if response.Metadata != nil {
assert.Equal(t, scenario, response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
// Verify MCP-specific data
if scenario == "mcp_health" {
assert.NotNil(t, response.Metadata["tools_count"], "Iteration %d: should have tools_count", i)
if toolsCount, ok := response.Metadata["tools_count"].(float64); ok {
assert.Greater(t, int(toolsCount), 0, "Iteration %d: should have at least 1 tool", i)
assert.Equal(t, 3, int(toolsCount), "Iteration %d: echo should have 3 tools", i)
}
assert.NotNil(t, response.Metadata["health_data"], "Iteration %d: should have health_data", i)
} else if scenario == "mcp_tools" {
assert.NotNil(t, response.Metadata["tools_count"], "Iteration %d: should have tools_count", i)
if toolsCount, ok := response.Metadata["tools_count"].(float64); ok {
assert.Equal(t, 3, int(toolsCount), "Iteration %d: echo should have 3 tools", i)
}
assert.NotNil(t, response.Metadata["operations"], "Iteration %d: should have operations", i)
if operations, ok := response.Metadata["operations"].([]interface{}); ok {
assert.Len(t, operations, 2, "Iteration %d: should have 2 operations (ping, status)", i)
}
}
} else {
t.Errorf("Iteration %d (%s): metadata is nil", i, scenario)
}
// Cleanup
done()
ctx.Release()
if i%10 == 0 {
runtime.GC()
currentMemory := getMemStats()
t.Logf("Iteration %d (%s): Memory: %d MB", i, scenario, currentMemory/1024/1024)
}
}
runtime.GC()
endMemory := getMemStats()
t.Logf("MCP stress: %d iterations", iterations)
t.Logf("Start memory: %d MB", startMemory/1024/1024)
t.Logf("End memory: %d MB", endMemory/1024/1024)
if endMemory > startMemory {
t.Logf("Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
} else {
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
}
}
// TestRealWorldStressFullWorkflow tests complete workflow under stress
func TestRealWorldStressFullWorkflow(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
iterations := 30
startMemory := getMemStats()
startTime := time.Now()
for i := 0; i < iterations; i++ {
ctx := newRealWorldContext(fmt.Sprintf("stress-workflow-%d", i), "tests.realworld")
// Initialize stack for trace
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
ctx.Stack = stack
messages := []context.Message{
{Role: "user", Content: "full_workflow"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Iteration %d failed: %v", i, err)
}
// Verify response
assert.NotNil(t, response, "Iteration %d: response should not be nil", i)
assert.NotEmpty(t, response.Messages, "Iteration %d: messages should not be empty", i)
if response.Metadata != nil {
assert.Equal(t, "full_workflow", response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
// Verify workflow-specific metadata
if phasesCompleted, ok := response.Metadata["phases_completed"]; ok {
phases := int(phasesCompleted.(float64))
assert.Equal(t, 4, phases, "Iteration %d: should complete 4 phases", i)
}
if mcpTools, ok := response.Metadata["mcp_tools"]; ok {
tools := int(mcpTools.(float64))
assert.Greater(t, tools, 0, "Iteration %d: should have MCP tools", i)
}
}
// Cleanup
done()
ctx.Release()
if i%10 == 0 {
runtime.GC()
currentMemory := getMemStats()
elapsed := time.Since(startTime)
t.Logf("Iteration %d: Memory: %d MB, Elapsed: %v", i, currentMemory/1024/1024, elapsed)
}
}
duration := time.Since(startTime)
runtime.GC()
endMemory := getMemStats()
avgTime := duration / time.Duration(iterations)
t.Logf("Full workflow stress: %d iterations", iterations)
t.Logf("Total time: %v", duration)
t.Logf("Average time per iteration: %v", avgTime)
t.Logf("Start memory: %d MB", startMemory/1024/1024)
t.Logf("End memory: %d MB", endMemory/1024/1024)
if endMemory > startMemory {
t.Logf("Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
} else {
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
}
}
// TestRealWorldStressConcurrent tests concurrent real-world usage
func TestRealWorldStressConcurrent(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
goroutines := 100
iterationsPerGoroutine := 10
scenarios := []string{"simple", "mcp_health", "mcp_tools", "full_workflow"}
startMemory := getMemStats()
startTime := time.Now()
var wg sync.WaitGroup
errors := make(chan error, goroutines*iterationsPerGoroutine)
// Track results for validation
type Result struct {
goroutineID int
iteration int
scenario string
metadata map[string]interface{}
}
results := make(chan Result, goroutines*iterationsPerGoroutine)
for g := 0; g < goroutines; g++ {
wg.Add(1)
go func(goroutineID int) {
defer wg.Done()
for i := 0; i < iterationsPerGoroutine; i++ {
scenario := scenarios[(goroutineID+i)%len(scenarios)]
ctx := newRealWorldContext(
fmt.Sprintf("concurrent-%d-%d", goroutineID, i),
"tests.realworld",
)
// Initialize stack for trace
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
ctx.Stack = stack
messages := []context.Message{
{Role: "user", Content: scenario},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
errors <- fmt.Errorf("goroutine %d iteration %d (%s): %v", goroutineID, i, scenario, err)
done()
ctx.Release()
return
}
// Validate response
if response == nil {
errors <- fmt.Errorf("goroutine %d iteration %d (%s): nil response", goroutineID, i, scenario)
done()
ctx.Release()
return
}
if len(response.Messages) == 0 {
errors <- fmt.Errorf("goroutine %d iteration %d (%s): empty messages", goroutineID, i, scenario)
done()
ctx.Release()
return
}
// Collect result
results <- Result{
goroutineID: goroutineID,
iteration: i,
scenario: scenario,
metadata: response.Metadata,
}
// Cleanup
done()
ctx.Release()
}
}(g)
}
wg.Wait()
close(errors)
close(results)
duration := time.Since(startTime)
runtime.GC()
endMemory := getMemStats()
// Check for errors
errorCount := 0
for err := range errors {
t.Error(err)
errorCount++
}
assert.Equal(t, 0, errorCount, "No errors should occur in concurrent operations")
// Validate results
scenarioCounts := make(map[string]int)
validResults := 0
for result := range results {
validResults++
scenarioCounts[result.scenario]++
// Validate metadata exists and has expected scenario
if result.metadata != nil {
if scenario, ok := result.metadata["scenario"].(string); ok {
if scenario != result.scenario {
t.Errorf("Metadata mismatch: expected %s, got %s (goroutine %d, iteration %d)",
result.scenario, scenario, result.goroutineID, result.iteration)
}
}
}
}
totalOperations := goroutines * iterationsPerGoroutine
assert.Equal(t, totalOperations, validResults, "All operations should return valid results")
avgTime := duration / time.Duration(totalOperations)
t.Logf("✓ Concurrent stress: %d operations (goroutines: %d, iterations: %d)",
totalOperations, goroutines, iterationsPerGoroutine)
t.Logf("✓ Valid results: %d/%d (100%%)", validResults, totalOperations)
t.Logf("✓ Scenario distribution:")
for scenario, count := range scenarioCounts {
t.Logf(" - %s: %d operations", scenario, count)
}
t.Logf("✓ Total time: %v", duration)
t.Logf("✓ Average time per operation: %v", avgTime)
t.Logf("✓ Start memory: %d MB", startMemory/1024/1024)
t.Logf("✓ End memory: %d MB", endMemory/1024/1024)
if endMemory > startMemory {
t.Logf("✓ Memory growth: %d MB", (endMemory-startMemory)/1024/1024)
} else {
t.Logf("✓ Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
}
}
// TestRealWorldStressResourceHeavy tests resource-intensive scenarios
func TestRealWorldStressResourceHeavy(t *testing.T) {
if testing.Short() {
t.Skip("Skipping stress test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
agent, err := assistant.Get("tests.realworld")
if err != nil {
t.Fatalf("Failed to get assistant: %v", err)
}
iterations := 20
startMemory := getMemStats()
startTime := time.Now()
for i := 0; i < iterations; i++ {
ctx := newRealWorldContext(fmt.Sprintf("stress-heavy-%d", i), "tests.realworld")
// Initialize stack for trace
stack, _, done := context.EnterStack(ctx, "tests.realworld", &context.Options{})
ctx.Stack = stack
messages := []context.Message{
{Role: "user", Content: "resource_heavy"},
}
response, _, err := agent.HookScript.Create(ctx, messages)
if err != nil {
t.Fatalf("Iteration %d failed: %v", i, err)
}
// Validate response
assert.NotNil(t, response, "Iteration %d: response should not be nil", i)
assert.NotEmpty(t, response.Messages, "Iteration %d: messages should not be empty", i)
if response.Metadata != nil {
assert.Equal(t, "resource_heavy", response.Metadata["scenario"], "Iteration %d: scenario mismatch", i)
// Verify resource-heavy metadata
if mcpIterations, ok := response.Metadata["mcp_iterations"]; ok {
iterations := int(mcpIterations.(float64))
assert.Equal(t, 5, iterations, "Iteration %d: should have 5 MCP iterations", i)
}
}
// Cleanup
done()
ctx.Release()
if i%5 == 0 {
runtime.GC()
currentMemory := getMemStats()
elapsed := time.Since(startTime)
t.Logf("Iteration %d: Memory: %d MB, Elapsed: %v", i, currentMemory/1024/1024, elapsed)
}
}
duration := time.Since(startTime)
runtime.GC()
endMemory := getMemStats()
avgTime := duration / time.Duration(iterations)
t.Logf("Resource heavy stress: %d iterations", iterations)
t.Logf("Total time: %v", duration)
t.Logf("Average time per iteration: %v", avgTime)
t.Logf("Start memory: %d MB", startMemory/1024/1024)
t.Logf("End memory: %d MB", endMemory/1024/1024)
if endMemory > startMemory {
memoryGrowth := int64(endMemory - startMemory)
t.Logf("Memory growth: %d MB", memoryGrowth/1024/1024)
// Allow up to 100MB growth for resource-heavy operations
assert.Less(t, memoryGrowth, int64(100*1024*1024), "Memory growth should be reasonable")
} else {
t.Logf("Memory decreased: %d MB", (startMemory-endMemory)/1024/1024)
}
}
// ============================================================================
// Helper Functions
// ============================================================================
// newRealWorldContext creates a Context for real-world testing
func newRealWorldContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "realworld-test-user",
ClientID: "realworld-test-client",
Scope: "openid profile email",
SessionID: "realworld-test-session",
UserID: "realworld-user-123",
TeamID: "realworld-team-456",
TenantID: "realworld-tenant-789",
RememberMe: true,
Constraints: types.DataConstraints{
OwnerOnly: false,
CreatorOnly: false,
EditorOnly: false,
TeamOnly: true,
Extra: map[string]interface{}{
"department": "engineering",
"region": "us-west",
"project": "yao-realworld-test",
},
},
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "RealWorldTest/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}
// getMemStats returns current memory allocation in bytes
func getMemStats() uint64 {
runtime.GC()
var m runtime.MemStats
runtime.ReadMemStats(&m)
return m.Alloc
}

View file

@ -1,45 +0,0 @@
package hook
import (
"strings"
"github.com/yaoapp/yao/agent/context"
)
// Execute execute the script
func (s *Script) Execute(ctx *context.Context, method string, args ...interface{}) (interface{}, error) {
if s == nil || s.Script == nil {
return nil, nil
}
var sid = ""
if ctx.Authorized != nil {
sid = ctx.Authorized.SessionID
}
scriptCtx, err := s.NewContext(sid, nil)
if err != nil {
return nil, err
}
defer scriptCtx.Close()
// Set authorized information if available
if ctx.Authorized != nil {
scriptCtx.WithAuthorized(ctx.Authorized.AuthorizedToMap())
}
// The first argument is the context
args = append([]interface{}{ctx}, args...)
// Try to call the method
result, err := scriptCtx.CallWith(ctx.Context, method, args...)
// If method doesn't exist (ReferenceError or similar), return nil without error
if err != nil && (strings.Contains(err.Error(), "is not defined") ||
strings.Contains(err.Error(), "is not a function") ||
strings.Contains(err.Error(), "is not a Function")) {
return nil, nil
}
return result, err
}

View file

@ -1,10 +0,0 @@
package hook
import (
v8 "github.com/yaoapp/gou/runtime/v8"
)
// Script the script hook align
type Script struct {
*v8.Script
}

View file

@ -1,135 +0,0 @@
package assistant
import (
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/llm"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/trace/types"
)
// executeLLMStream executes the LLM streaming call with pre-built request
// Returns completionResponse and error
func (ast *Assistant) executeLLMStream(
ctx *context.Context,
completionMessages []context.Message,
completionOptions *context.CompletionOptions,
agentNode types.Node,
streamHandler message.StreamFunc,
opts *context.Options,
) (*context.CompletionResponse, error) {
// Get connector object (capabilities were already set above, before stream_start)
conn, capabilities, err := ast.GetConnector(ctx, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
return nil, err
}
// Set capabilities in options if not already set
if completionOptions.Capabilities == nil && capabilities != nil {
completionOptions.Capabilities = capabilities
}
// Log the capabilities
ast.traceConnectorCapabilities(agentNode, capabilities)
// Build content - convert extended types (file, data, __yao.attachment://) to standard LLM types
// This is done here (right before LLM call) to ensure:
// 1. autoSearch receives original messages (not converted)
// 2. delegate receives original messages (not converted)
// 3. Only the actual LLM call sees converted messages
llmMessages, err := ast.BuildContent(ctx, completionMessages, completionOptions, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
return nil, err
}
// Trace Add LLM request (use converted messages for trace)
ast.traceLLMRequest(ctx, conn.ID(), llmMessages, completionOptions)
// Log LLM call start
ctx.Logger.LLMStart(conn.ID(), "", len(llmMessages))
// Create LLM instance with connector and options
llmInstance, err := llm.New(conn, completionOptions)
if err != nil {
// Mark LLM Request as failed in trace
ast.traceLLMFail(ctx, err)
return nil, err
}
// Call the LLM Completion Stream (streamHandler was set earlier)
// Use llmMessages (converted) instead of completionMessages (original)
completionResponse, err := llmInstance.Stream(ctx, llmMessages, completionOptions, streamHandler)
if err != nil {
// Mark LLM Request as failed in trace
ast.traceLLMFail(ctx, err)
return nil, err
}
// Mark LLM Request Complete
ast.traceLLMComplete(ctx, completionResponse)
return completionResponse, nil
}
// executeLLMForToolRetry executes LLM call for tool retry with streaming output
// This is used when retrying tool calls - we still want to show LLM's response to users
// Returns completionResponse and error
func (ast *Assistant) executeLLMForToolRetry(
ctx *context.Context,
completionMessages []context.Message,
completionOptions *context.CompletionOptions,
agentNode types.Node,
streamHandler message.StreamFunc,
opts *context.Options,
) (*context.CompletionResponse, error) {
// Get connector object
conn, capabilities, err := ast.GetConnector(ctx, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
return nil, err
}
// Set capabilities in options if not already set
if completionOptions.Capabilities == nil && capabilities != nil {
completionOptions.Capabilities = capabilities
}
// Build content - convert extended types for LLM call
llmMessages, err := ast.BuildContent(ctx, completionMessages, completionOptions, opts)
if err != nil {
ast.traceAgentFail(agentNode, err)
return nil, err
}
// Trace Add LLM retry request
ast.traceLLMRetryRequest(ctx, conn.ID(), llmMessages, completionOptions)
// Log LLM call start (retry)
ctx.Logger.LLMStart(conn.ID(), "", len(llmMessages))
// Create LLM instance with connector and options
llmInstance, err := llm.New(conn, completionOptions)
if err != nil {
// Mark LLM Retry Request as failed in trace
ast.traceLLMFail(ctx, err)
return nil, err
}
// Call the LLM Completion Stream (still streaming for tool retry)
// Use llmMessages (converted) instead of completionMessages (original)
completionResponse, err := llmInstance.Stream(ctx, llmMessages, completionOptions, streamHandler)
if err != nil {
// Mark LLM Retry Request as failed in trace
ast.traceLLMFail(ctx, err)
return nil, err
}
// Mark LLM Request Complete
ast.traceLLMComplete(ctx, completionResponse)
return completionResponse, nil
}

File diff suppressed because it is too large Load diff

View file

@ -1,366 +0,0 @@
package assistant_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/testutils"
)
// TestLoadPathMerge tests loading the merge test assistant
// This verifies that global config is properly merged with assistant-specific config
func TestLoadPathMerge(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/merge")
require.NoError(t, err)
require.NotNil(t, ast)
assert.Equal(t, "tests.merge", ast.ID)
assert.Equal(t, "Merge Config Test Assistant", ast.Name)
// Uses configuration - should merge global with assistant-specific
// Global (from agent/agent.yml):
// vision: "workers.system.vision"
// search: "workers.system.search"
// fetch: "workers.system.fetch"
// audio: (not set)
// querydsl: (not set)
// rerank: (not set)
// Assistant:
// web: "mcp:custom-web"
// keyword: "mcp:custom-keyword"
// Result: assistant values override, global values inherited
assert.NotNil(t, ast.Uses)
// Assistant overrides
assert.Equal(t, "mcp:custom-web", ast.Uses.Web) // overridden by assistant
assert.Equal(t, "mcp:custom-keyword", ast.Uses.Keyword) // overridden by assistant
// Inherited from global (agent/agent.yml)
assert.Equal(t, "workers.system.vision", ast.Uses.Vision) // inherited from global
assert.Equal(t, "workers.system.search", ast.Uses.Search) // inherited from global
assert.Equal(t, "workers.system.fetch", ast.Uses.Fetch) // inherited from global
// Not set in either global or assistant (should be empty)
assert.Empty(t, ast.Uses.Audio) // not set anywhere
assert.Empty(t, ast.Uses.QueryDSL) // not set anywhere
assert.Empty(t, ast.Uses.Rerank) // not set anywhere
// Search configuration - should merge global with assistant-specific
// Global (from agent/search.yml):
// web.provider=tavily, web.max_results=10
// kb.threshold=0.7, kb.graph=false
// db.max_results=20
// keyword.max_keywords=10, keyword.language=auto
// rerank.top_n=10
// citation.format=#ref:{id}, citation.auto_inject_prompt=true
// weights: user=1.0, hook=0.8, auto=0.6
// options.skip_threshold=5
// Assistant:
// web.provider=custom-provider, web.max_results=25
// kb.collections=[merge-test-kb], kb.threshold=0.85
assert.NotNil(t, ast.Search)
// Web config - assistant overrides global
assert.NotNil(t, ast.Search.Web)
assert.Equal(t, "custom-provider", ast.Search.Web.Provider) // overridden
assert.Equal(t, 25, ast.Search.Web.MaxResults) // overridden
// KB config - assistant overrides global
assert.NotNil(t, ast.Search.KB)
assert.Equal(t, []string{"merge-test-kb"}, ast.Search.KB.Collections) // overridden
assert.Equal(t, 0.85, ast.Search.KB.Threshold) // overridden
assert.False(t, ast.Search.KB.Graph) // inherited from global
// DB config - should inherit from global (assistant doesn't define it)
assert.NotNil(t, ast.Search.DB)
assert.Equal(t, 20, ast.Search.DB.MaxResults) // inherited from global
// Keyword config - should inherit from global
assert.NotNil(t, ast.Search.Keyword)
assert.Equal(t, 10, ast.Search.Keyword.MaxKeywords) // inherited from global
assert.Equal(t, "auto", ast.Search.Keyword.Language) // inherited from global
// Rerank config - should inherit from global
assert.NotNil(t, ast.Search.Rerank)
assert.Equal(t, 10, ast.Search.Rerank.TopN) // inherited from global
// Citation config - should inherit from global
assert.NotNil(t, ast.Search.Citation)
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format) // inherited from global
assert.True(t, ast.Search.Citation.AutoInjectPrompt) // inherited from global
// Weights config - should inherit from global
assert.NotNil(t, ast.Search.Weights)
assert.Equal(t, 1.0, ast.Search.Weights.User) // inherited from global
assert.Equal(t, 0.8, ast.Search.Weights.Hook) // inherited from global
assert.Equal(t, 0.6, ast.Search.Weights.Auto) // inherited from global
// Options config - should inherit from global
assert.NotNil(t, ast.Search.Options)
assert.Equal(t, 5, ast.Search.Options.SkipThreshold) // inherited from global
}
// TestLoadPathMergeOverride tests loading the merge-override test assistant
// This verifies that assistant config completely overrides global config
func TestLoadPathMergeOverride(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/merge-override")
require.NoError(t, err)
require.NotNil(t, ast)
assert.Equal(t, "tests.merge-override", ast.ID)
assert.Equal(t, "Merge Override Test Assistant", ast.Name)
// Uses configuration - all fields should be overridden by assistant
assert.NotNil(t, ast.Uses)
assert.Equal(t, "mcp:custom-vision", ast.Uses.Vision)
assert.Equal(t, "mcp:custom-audio", ast.Uses.Audio)
assert.Equal(t, "mcp:custom-search", ast.Uses.Search)
assert.Equal(t, "mcp:custom-fetch", ast.Uses.Fetch)
assert.Equal(t, "mcp:custom-web", ast.Uses.Web)
assert.Equal(t, "mcp:custom-keyword", ast.Uses.Keyword)
assert.Equal(t, "mcp:custom-querydsl", ast.Uses.QueryDSL)
assert.Equal(t, "mcp:custom-rerank", ast.Uses.Rerank)
// Search configuration - all fields should be overridden by assistant
assert.NotNil(t, ast.Search)
// Web config - all overridden
assert.NotNil(t, ast.Search.Web)
assert.Equal(t, "override-provider", ast.Search.Web.Provider)
assert.Equal(t, "$ENV.OVERRIDE_API_KEY", ast.Search.Web.APIKeyEnv)
assert.Equal(t, 100, ast.Search.Web.MaxResults)
// KB config - all overridden
assert.NotNil(t, ast.Search.KB)
assert.Equal(t, []string{"override-kb"}, ast.Search.KB.Collections)
assert.Equal(t, 0.99, ast.Search.KB.Threshold)
assert.True(t, ast.Search.KB.Graph)
// DB config - all overridden
assert.NotNil(t, ast.Search.DB)
assert.Equal(t, []string{"override-model"}, ast.Search.DB.Models)
assert.Equal(t, 200, ast.Search.DB.MaxResults)
// Keyword config - all overridden
assert.NotNil(t, ast.Search.Keyword)
assert.Equal(t, 20, ast.Search.Keyword.MaxKeywords)
assert.Equal(t, "zh", ast.Search.Keyword.Language)
// QueryDSL config - overridden
assert.NotNil(t, ast.Search.QueryDSL)
assert.True(t, ast.Search.QueryDSL.Strict)
// Rerank config - overridden
assert.NotNil(t, ast.Search.Rerank)
assert.Equal(t, 20, ast.Search.Rerank.TopN)
// Citation config - all overridden
assert.NotNil(t, ast.Search.Citation)
assert.Equal(t, "[override:{id}]", ast.Search.Citation.Format)
assert.False(t, ast.Search.Citation.AutoInjectPrompt)
assert.Equal(t, "Override citation prompt", ast.Search.Citation.CustomPrompt)
// Weights config - all overridden
assert.NotNil(t, ast.Search.Weights)
assert.Equal(t, 2.0, ast.Search.Weights.User)
assert.Equal(t, 1.5, ast.Search.Weights.Hook)
assert.Equal(t, 1.0, ast.Search.Weights.Auto)
// Options config - overridden
assert.NotNil(t, ast.Search.Options)
assert.Equal(t, 10, ast.Search.Options.SkipThreshold)
}
// TestLoadPathMergeEmpty tests loading the merge-empty test assistant
// This verifies that assistant with no uses/search config inherits all from global
func TestLoadPathMergeEmpty(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/merge-empty")
require.NoError(t, err)
require.NotNil(t, ast)
assert.Equal(t, "tests.merge-empty", ast.ID)
assert.Equal(t, "Merge Empty Test Assistant", ast.Name)
// Uses configuration - all inherited from global (agent/agent.yml)
assert.NotNil(t, ast.Uses)
assert.Equal(t, "workers.system.vision", ast.Uses.Vision) // from global
assert.Equal(t, "workers.system.search", ast.Uses.Search) // from global
assert.Equal(t, "workers.system.fetch", ast.Uses.Fetch) // from global
assert.Empty(t, ast.Uses.Audio) // not set in global
assert.Empty(t, ast.Uses.Web) // not set in global
assert.Empty(t, ast.Uses.Keyword) // not set in global
assert.Empty(t, ast.Uses.QueryDSL) // not set in global
assert.Empty(t, ast.Uses.Rerank) // not set in global
// Search configuration - all inherited from global (agent/search.yml)
assert.NotNil(t, ast.Search)
// Web config - from global
assert.NotNil(t, ast.Search.Web)
assert.Equal(t, "tavily", ast.Search.Web.Provider)
assert.Equal(t, 10, ast.Search.Web.MaxResults)
// KB config - from global
assert.NotNil(t, ast.Search.KB)
assert.Equal(t, 0.7, ast.Search.KB.Threshold)
assert.False(t, ast.Search.KB.Graph)
// DB config - from global
assert.NotNil(t, ast.Search.DB)
assert.Equal(t, 20, ast.Search.DB.MaxResults)
// Keyword config - from global
assert.NotNil(t, ast.Search.Keyword)
assert.Equal(t, 10, ast.Search.Keyword.MaxKeywords)
assert.Equal(t, "auto", ast.Search.Keyword.Language)
// Rerank config - from global
assert.NotNil(t, ast.Search.Rerank)
assert.Equal(t, 10, ast.Search.Rerank.TopN)
// Citation config - from global
assert.NotNil(t, ast.Search.Citation)
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format)
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
// Weights config - from global
assert.NotNil(t, ast.Search.Weights)
assert.Equal(t, 1.0, ast.Search.Weights.User)
assert.Equal(t, 0.8, ast.Search.Weights.Hook)
assert.Equal(t, 0.6, ast.Search.Weights.Auto)
// Options config - from global
assert.NotNil(t, ast.Search.Options)
assert.Equal(t, 5, ast.Search.Options.SkipThreshold)
}
// TestLoadPathUsesAndSearchMerge tests loading fullfields assistant
// This verifies that uses and search configs are properly loaded and merged
func TestLoadPathUsesAndSearchMerge(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, ast)
// Uses configuration - assistant-specific values
assert.NotNil(t, ast.Uses)
assert.Equal(t, "agent", ast.Uses.Vision)
assert.Equal(t, "mcp:audio-server", ast.Uses.Audio)
assert.Equal(t, "agent", ast.Uses.Fetch)
assert.Equal(t, "builtin", ast.Uses.Web)
assert.Equal(t, "builtin", ast.Uses.Keyword)
assert.Equal(t, "builtin", ast.Uses.QueryDSL)
assert.Equal(t, "builtin", ast.Uses.Rerank)
// Search configuration - assistant-specific values
assert.NotNil(t, ast.Search)
// Web config - from assistant
assert.NotNil(t, ast.Search.Web)
assert.Equal(t, "tavily", ast.Search.Web.Provider)
assert.Equal(t, 15, ast.Search.Web.MaxResults)
// KB config - from assistant
assert.NotNil(t, ast.Search.KB)
assert.Equal(t, []string{"docs", "faq"}, ast.Search.KB.Collections)
assert.Equal(t, 0.8, ast.Search.KB.Threshold)
assert.True(t, ast.Search.KB.Graph)
// DB config - from assistant
assert.NotNil(t, ast.Search.DB)
assert.Equal(t, []string{"user", "product"}, ast.Search.DB.Models)
assert.Equal(t, 50, ast.Search.DB.MaxResults)
// Citation config - from assistant
assert.NotNil(t, ast.Search.Citation)
assert.Equal(t, "#ref:{id}", ast.Search.Citation.Format)
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
// Weights config - from assistant
assert.NotNil(t, ast.Search.Weights)
assert.Equal(t, 1.0, ast.Search.Weights.User)
assert.Equal(t, 0.9, ast.Search.Weights.Hook)
assert.Equal(t, 0.7, ast.Search.Weights.Auto)
}
// TestLoadPathSearchAssistant tests loading the dedicated search test assistant
func TestLoadPathSearchAssistant(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/search")
require.NoError(t, err)
require.NotNil(t, ast)
assert.Equal(t, "tests.search", ast.ID)
assert.Equal(t, "Search Config Test Assistant", ast.Name)
// Uses configuration
assert.NotNil(t, ast.Uses)
assert.Equal(t, "builtin", ast.Uses.Web)
assert.Equal(t, "builtin", ast.Uses.Keyword)
assert.Equal(t, "builtin", ast.Uses.QueryDSL)
assert.Equal(t, "builtin", ast.Uses.Rerank)
// Search configuration
assert.NotNil(t, ast.Search)
// Web config
assert.NotNil(t, ast.Search.Web)
assert.Equal(t, "serper", ast.Search.Web.Provider)
assert.Equal(t, "$ENV.SERPER_API_KEY", ast.Search.Web.APIKeyEnv)
assert.Equal(t, 20, ast.Search.Web.MaxResults)
// KB config
assert.NotNil(t, ast.Search.KB)
assert.Equal(t, []string{"knowledge-base", "documents"}, ast.Search.KB.Collections)
assert.Equal(t, 0.75, ast.Search.KB.Threshold)
assert.False(t, ast.Search.KB.Graph)
// DB config
assert.NotNil(t, ast.Search.DB)
assert.Equal(t, []string{"article", "comment"}, ast.Search.DB.Models)
assert.Equal(t, 30, ast.Search.DB.MaxResults)
// Keyword config
assert.NotNil(t, ast.Search.Keyword)
assert.Equal(t, 8, ast.Search.Keyword.MaxKeywords)
assert.Equal(t, "auto", ast.Search.Keyword.Language)
// QueryDSL config
assert.NotNil(t, ast.Search.QueryDSL)
assert.True(t, ast.Search.QueryDSL.Strict)
// Rerank config
assert.NotNil(t, ast.Search.Rerank)
assert.Equal(t, 5, ast.Search.Rerank.TopN)
// Citation config
assert.NotNil(t, ast.Search.Citation)
assert.Equal(t, "#cite:{id}", ast.Search.Citation.Format)
assert.False(t, ast.Search.Citation.AutoInjectPrompt)
assert.Equal(t, "Please cite sources using #cite:{id} format.", ast.Search.Citation.CustomPrompt)
// Weights config
assert.NotNil(t, ast.Search.Weights)
assert.Equal(t, 1.0, ast.Search.Weights.User)
assert.Equal(t, 0.85, ast.Search.Weights.Hook)
assert.Equal(t, 0.65, ast.Search.Weights.Auto)
// Options config
assert.NotNil(t, ast.Search.Options)
assert.Equal(t, 3, ast.Search.Options.SkipThreshold)
}

View file

@ -1,120 +0,0 @@
package assistant_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/agent/testutils"
)
func TestLoadProcessIntegration(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// After testutils.Prepare, all assistants should be loaded and scripts registered
// Test calling mcpload assistant's tools.Hello function
t.Run("CallHelloAfterLoad", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.Hello", map[string]interface{}{
"name": "TestUser",
})
err := proc.Execute()
assert.NoError(t, err)
result := proc.Value()
assert.NotNil(t, result)
resultStr, ok := result.(string)
assert.True(t, ok, "Result should be a string")
assert.Contains(t, resultStr, "Hello, TestUser")
assert.Contains(t, resultStr, "mcpload assistant")
})
t.Run("CallPingAfterLoad", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.Ping", map[string]interface{}{
"message": "integration test",
})
err := proc.Execute()
assert.NoError(t, err)
result := proc.Value()
assert.NotNil(t, result)
resultMap, ok := result.(map[string]interface{})
assert.True(t, ok, "Result should be a map")
assert.Equal(t, "integration test", resultMap["message"])
assert.Contains(t, resultMap["echo"], "Pong")
assert.NotEmpty(t, resultMap["timestamp"])
})
t.Run("CallCalculateAfterLoad", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.Calculate", map[string]interface{}{
"operation": "add",
"a": float64(100),
"b": float64(50),
})
err := proc.Execute()
assert.NoError(t, err)
result := proc.Value()
assert.NotNil(t, result)
resultMap, ok := result.(map[string]interface{})
assert.True(t, ok, "Result should be a map")
assert.Equal(t, float64(150), resultMap["result"])
assert.Equal(t, "add", resultMap["operation"])
assert.Equal(t, float64(100), resultMap["a"])
assert.Equal(t, float64(50), resultMap["b"])
})
t.Run("CallNonExistentScript", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.nonexistent.Method")
err := proc.Execute()
assert.NotNil(t, err, "Should return error for non-existent script")
assert.Contains(t, err.Error(), "Exception|404")
})
t.Run("CallNonExistentMethod", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.NonExistentMethod")
err := proc.Execute()
assert.NotNil(t, err, "Should return error for non-existent method")
assert.Contains(t, err.Error(), "Exception|500")
})
}
func TestLoadProcessMultipleAssistants(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Test that multiple assistants can have their scripts registered
// and process calls work correctly for different assistants
t.Run("MCPLoadAssistant", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.Hello", map[string]interface{}{
"name": "User1",
})
err := proc.Execute()
assert.NoError(t, err)
result := proc.Value()
resultStr, ok := result.(string)
assert.True(t, ok)
assert.Contains(t, resultStr, "mcpload assistant")
})
// If there are other test assistants with scripts, they can be tested here
// For now, we verify that the handler is properly isolated per assistant
t.Run("VerifyIsolation", func(t *testing.T) {
// Verify that the mcpload handler is correctly registered
handler, exists := process.Handlers["agents.tests.mcpload.tools"]
assert.True(t, exists, "Handler should be registered")
assert.NotNil(t, handler)
})
}

File diff suppressed because it is too large Load diff

View file

@ -1,353 +0,0 @@
package assistant
import (
"fmt"
"path/filepath"
"strings"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/gou/connector"
gouOpenAI "github.com/yaoapp/gou/connector/openai"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/i18n"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/data"
"gopkg.in/yaml.v3"
)
// systemAgents defines the system agents loaded from bindata
// These are internal agents used by the system (e.g., keyword extraction, querydsl generation)
// The directory name is without __yao. prefix, prefix is added during loading
// Format: directory name -> bindata path prefix
var systemAgents = []string{
"keyword",
"querydsl",
"title",
"prompt",
"robot_prompt",
"needsearch",
"entity",
"vision",
"fetch",
"loop_fallback",
}
// SystemConfig holds the system agents connector configuration
// This is set from agent.yml system block
type SystemConfig struct {
// Role-level defaults (consumed by buildSystemRoles → SetDefaults)
Default string // Default connector for the "default" role
Light string // Default connector for the "light" role
Vision string // Default connector for the "vision" role
Audio string // Default connector for the "audio" role
Heavy string // Default connector for the "heavy" role (complex reasoning)
// Per-agent overrides (consumed by resolveSystemConnector → ast.Connector)
Keyword string // Connector for __yao.keyword agent
QueryDSL string // Connector for __yao.querydsl agent
Title string // Connector for __yao.title agent
Prompt string // Connector for __yao.prompt agent
RobotPrompt string // Connector for __yao.robot_prompt agent
NeedSearch string // Connector for __yao.needsearch agent
Entity string // Connector for __yao.entity agent
LoopFallback string // Connector for __yao.loop_fallback agent
}
// systemConfig holds the system agents configuration (global variable like others in load.go)
var systemConfig *SystemConfig = nil
// SetSystemConfig sets the system agents configuration
func SetSystemConfig(config *SystemConfig) {
systemConfig = config
}
// GetSystemConfig returns the system agents configuration
func GetSystemConfig() *SystemConfig {
return systemConfig
}
// LoadSystemAgents loads the system agents from bindata
// These are internal agents like __yao.keyword and __yao.querydsl
// They are loaded before application assistants
// Behavior is same as LoadBuiltIn, just reads from bindata instead of filesystem
func LoadSystemAgents() error {
// Get all existing system agents (for cleanup)
deletedSystem := map[string]bool{}
if storage != nil {
// System agents have "system" tag
tags := []string{"system"}
builtIn := true
res, err := storage.GetAssistants(store.AssistantFilter{
Tags: tags,
BuiltIn: &builtIn,
Select: []string{"assistant_id", "id"},
})
if err != nil {
log.Warn("Failed to get existing system agents: %v", err)
} else {
for _, assistant := range res.Data {
deletedSystem[assistant.ID] = true
}
}
}
sort := 1
for _, name := range systemAgents {
// Build agent ID with __yao. prefix
id := "__yao." + name
pathPrefix := "yao/assistants/" + name
assistant, err := loadSystemAgent(id, pathPrefix)
if err != nil {
log.Warn("Failed to load system agent %s: %v", id, err)
continue
}
// Set sort order
if assistant.Sort == 0 {
assistant.Sort = sort
}
// Save to storage
if err := assistant.Save(); err != nil {
log.Warn("Failed to save system agent %s: %v", id, err)
continue
}
// Initialize the assistant
if err := assistant.initialize(); err != nil {
log.Warn("Failed to initialize system agent %s: %v", id, err)
continue
}
sort++
loaded.Put(assistant)
log.Trace("Loaded system agent: %s", id)
// Remove from deleted list
delete(deletedSystem, id)
}
// Remove deleted system agents
if len(deletedSystem) > 0 {
assistantIDs := []string{}
for assistantID := range deletedSystem {
assistantIDs = append(assistantIDs, assistantID)
}
if _, err := storage.DeleteAssistants(store.AssistantFilter{AssistantIDs: assistantIDs}); err != nil {
log.Warn("Failed to delete obsolete system agents: %v", err)
}
}
return nil
}
// loadSystemAgent loads a single system agent from bindata
// This follows the same pattern as LoadPath but reads from bindata
func loadSystemAgent(id, pathPrefix string) (*Assistant, error) {
// Read package.yao from bindata
pkgPath := pathPrefix + "/package.yao"
pkgContent, err := data.Read(pkgPath)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", pkgPath, err)
}
// Parse package.yao
var pkgData map[string]interface{}
if err := application.Parse(pkgPath, pkgContent, &pkgData); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", pkgPath, err)
}
// Set assistant_id (no path - system agents are loaded from storage, not filesystem)
pkgData["assistant_id"] = id
// Set type if not specified
if _, has := pkgData["type"]; !has {
pkgData["type"] = "assistant"
}
// Override connector only if agent.yml has an explicit per-agent setting
if override := resolveSystemConnector(id); override != "" {
pkgData["connector"] = override
}
// Read prompts.yml from bindata (default prompts)
promptsPath := pathPrefix + "/prompts.yml"
promptsContent, err := data.Read(promptsPath)
if err == nil {
var prompts []store.Prompt
if err := yaml.Unmarshal(promptsContent, &prompts); err == nil && len(prompts) > 0 {
pkgData["prompts"] = prompts
}
}
// Read prompt_presets from prompts directory
presets := loadSystemPromptPresets(pathPrefix)
if len(presets) > 0 {
pkgData["prompt_presets"] = presets
}
// Load scripts from src directory (hook script source and other scripts sources)
// These will be compiled by loadMap -> LoadScriptsFromData
hookScriptSource, scriptsSource := loadSystemScripts(pathPrefix)
if hookScriptSource != "" {
pkgData["script"] = hookScriptSource
}
if len(scriptsSource) > 0 {
pkgData["scripts"] = scriptsSource
}
// Read locales
locales, err := loadSystemLocales(pathPrefix)
if err == nil && len(locales) > 0 {
pkgData["locales"] = locales
}
// Mark as system agent
pkgData["readonly"] = true
pkgData["built_in"] = true
pkgData["tags"] = []string{"system"}
// Load from map (same as LoadPath, includes initialize())
return loadMap(pkgData)
}
// resolveSystemConnector returns an explicit per-agent connector override from agent.yml.
// Returns empty string if no override exists, so the connector declared in package.yao
// (e.g. "use::light") is preserved as-is.
func resolveSystemConnector(agentID string) string {
if systemConfig == nil {
return ""
}
switch agentID {
case "__yao.keyword":
return systemConfig.Keyword
case "__yao.querydsl":
return systemConfig.QueryDSL
case "__yao.title":
return systemConfig.Title
case "__yao.prompt":
return systemConfig.Prompt
case "__yao.robot_prompt":
return systemConfig.RobotPrompt
case "__yao.needsearch":
return systemConfig.NeedSearch
case "__yao.entity":
return systemConfig.Entity
case "__yao.vision":
return systemConfig.Vision
case "__yao.audio":
return systemConfig.Audio
case "__yao.loop_fallback":
return systemConfig.LoopFallback
}
return ""
}
// findCapableConnector finds the first connector that supports tool calling
func findCapableConnector() string {
for id, conn := range connector.Connectors {
if !conn.Is(connector.OPENAI) {
continue
}
if connOpenAI, ok := conn.(*gouOpenAI.Connector); ok {
if connOpenAI.Options.Capabilities != nil && connOpenAI.Options.Capabilities.ToolCalls {
return id
}
}
}
return ""
}
// loadSystemPromptPresets loads prompt presets from bindata prompts directory
func loadSystemPromptPresets(pathPrefix string) map[string][]store.Prompt {
presets := make(map[string][]store.Prompt)
promptsDir := pathPrefix + "/prompts"
// Try common preset files
presetFiles := []string{"chat.yml", "task.yml", "code.yml", "analysis.yml"}
for _, filename := range presetFiles {
presetPath := promptsDir + "/" + filename
content, err := data.Read(presetPath)
if err != nil {
continue
}
var prompts []store.Prompt
if err := yaml.Unmarshal(content, &prompts); err == nil && len(prompts) > 0 {
presetName := strings.TrimSuffix(filename, ".yml")
presets[presetName] = prompts
}
}
return presets
}
// loadSystemScripts loads scripts source from bindata src directory
// Returns hook script source and other scripts sources (as strings)
// These will be compiled by loadMap -> LoadScriptsFromData
func loadSystemScripts(pathPrefix string) (string, map[string]string) {
srcDir := pathPrefix + "/src"
// Try to load hook script (index.ts)
var hookScriptSource string
indexPath := srcDir + "/index.ts"
indexContent, err := data.Read(indexPath)
if err == nil && len(indexContent) > 0 {
hookScriptSource = string(indexContent)
}
// Try to load other scripts
scripts := make(map[string]string)
scriptFiles := []string{"utils.ts", "helpers.ts", "tools.ts"}
for _, filename := range scriptFiles {
scriptPath := srcDir + "/" + filename
content, err := data.Read(scriptPath)
if err != nil {
continue
}
scriptName := strings.TrimSuffix(filename, ".ts")
scripts[scriptName] = string(content)
}
if len(scripts) == 0 {
scripts = nil
}
return hookScriptSource, scripts
}
// loadSystemLocales loads locales from bindata
func loadSystemLocales(pathPrefix string) (i18n.Map, error) {
locales := make(i18n.Map)
// Try to load common locale files
localeFiles := []string{"en-us.yml", "zh-cn.yml", "en.yml", "zh.yml"}
localesDir := pathPrefix + "/locales"
for _, filename := range localeFiles {
localePath := filepath.Join(localesDir, filename)
content, err := data.Read(localePath)
if err != nil {
continue
}
// Parse locale file
locale := strings.TrimSuffix(filename, ".yml")
var messages map[string]any
if err := yaml.Unmarshal(content, &messages); err != nil {
continue
}
locales[locale] = i18n.I18n{
Locale: locale,
Messages: messages,
}
}
return locales, nil
}

View file

@ -1,58 +0,0 @@
package assistant
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestResolveSystemConnector_NoConfig(t *testing.T) {
saved := systemConfig
systemConfig = nil
defer func() { systemConfig = saved }()
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_PerAgentOverride(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Title: "openai.gpt-4o",
}
defer func() { systemConfig = saved }()
assert.Equal(t, "openai.gpt-4o", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_RoleLevelOnly(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Default: "openai.gpt-4o",
Light: "openai.gpt-4o-mini",
}
defer func() { systemConfig = saved }()
// Role-level keys don't produce per-agent overrides
assert.Equal(t, "", resolveSystemConnector("__yao.title"))
assert.Equal(t, "", resolveSystemConnector("__yao.keyword"))
assert.Equal(t, "", resolveSystemConnector("__yao.querydsl"))
assert.Equal(t, "", resolveSystemConnector("__yao.vision"))
}
func TestResolveSystemConnector_UnknownAgent(t *testing.T) {
saved := systemConfig
systemConfig = &SystemConfig{
Default: "openai.gpt-4o",
Title: "openai.gpt-4o",
}
defer func() { systemConfig = saved }()
assert.Equal(t, "", resolveSystemConnector("__yao.nonexistent"))
assert.Equal(t, "", resolveSystemConnector("custom.agent"))
}

View file

@ -1,803 +0,0 @@
package assistant_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/agent/assistant"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
func prepare(t *testing.T) {
test.Prepare(t, config.Conf)
}
func prepareAgent(t *testing.T) {
test.Prepare(t, config.Conf)
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
}
// TestLoadPath tests loading assistant from path
func TestLoadPath(t *testing.T) {
prepare(t)
defer test.Clean()
t.Run("LoadFullFieldsAssistant", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// Basic fields
assert.Equal(t, "tests.fullfields", assistant.ID)
assert.Equal(t, "Full Fields Test Assistant", assistant.Name)
assert.Equal(t, "assistant", assistant.Type)
assert.Equal(t, "/api/__yao/app/icons/app.png", assistant.Avatar)
assert.Equal(t, "gpt-4o", assistant.Connector)
assert.Equal(t, "/assistants/tests/fullfields", assistant.Path)
assert.Equal(t, "Test assistant with all available fields for unit testing", assistant.Description)
// Boolean fields
assert.True(t, assistant.Public)
assert.True(t, assistant.Readonly)
assert.True(t, assistant.Mentionable)
assert.False(t, assistant.Automated)
assert.True(t, assistant.DisableGlobalPrompts)
// Share field
assert.Equal(t, "team", assistant.Share)
// Sort field
assert.Equal(t, 100, assistant.Sort)
// Tags
assert.NotNil(t, assistant.Tags)
assert.Contains(t, assistant.Tags, "Test")
assert.Contains(t, assistant.Tags, "Development")
assert.Contains(t, assistant.Tags, "FullFields")
// Options
assert.NotNil(t, assistant.Options)
assert.Equal(t, 0.7, assistant.Options["temperature"])
assert.Equal(t, float64(2000), assistant.Options["max_tokens"])
// Prompts (default prompts from prompts.yml)
assert.NotNil(t, assistant.Prompts)
assert.GreaterOrEqual(t, len(assistant.Prompts), 1)
assert.Equal(t, "system", assistant.Prompts[0].Role)
// Script (from src/index.ts)
assert.NotNil(t, assistant.HookScript)
})
t.Run("LoadConnectorOptions", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// ConnectorOptions
assert.NotNil(t, assistant.ConnectorOptions)
assert.NotNil(t, assistant.ConnectorOptions.Optional)
assert.True(t, *assistant.ConnectorOptions.Optional)
assert.NotNil(t, assistant.ConnectorOptions.Connectors)
assert.Contains(t, assistant.ConnectorOptions.Connectors, "gpt-4o")
assert.Contains(t, assistant.ConnectorOptions.Connectors, "gpt-4o-mini")
assert.Contains(t, assistant.ConnectorOptions.Connectors, "deepseek")
assert.NotNil(t, assistant.ConnectorOptions.Filters)
assert.Len(t, assistant.ConnectorOptions.Filters, 2)
})
t.Run("LoadPromptPresets", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// PromptPresets (from prompts directory)
assert.NotNil(t, assistant.PromptPresets)
// Top-level presets: chat.yml -> "chat", task.yml -> "task"
chatPreset, hasChat := assistant.PromptPresets["chat"]
assert.True(t, hasChat, "Should have 'chat' preset")
assert.NotEmpty(t, chatPreset)
taskPreset, hasTask := assistant.PromptPresets["task"]
assert.True(t, hasTask, "Should have 'task' preset")
assert.NotEmpty(t, taskPreset)
// Nested presets: chat/friendly.yml -> "chat.friendly"
friendlyPreset, hasFriendly := assistant.PromptPresets["chat.friendly"]
assert.True(t, hasFriendly, "Should have 'chat.friendly' preset")
assert.NotEmpty(t, friendlyPreset)
professionalPreset, hasProfessional := assistant.PromptPresets["chat.professional"]
assert.True(t, hasProfessional, "Should have 'chat.professional' preset")
assert.NotEmpty(t, professionalPreset)
// task/analysis.yml -> "task.analysis"
analysisPreset, hasAnalysis := assistant.PromptPresets["task.analysis"]
assert.True(t, hasAnalysis, "Should have 'task.analysis' preset")
assert.NotEmpty(t, analysisPreset)
})
t.Run("LoadKnowledgeBase", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// KB
assert.NotNil(t, assistant.KB)
assert.NotNil(t, assistant.KB.Collections)
assert.Contains(t, assistant.KB.Collections, "test-collection")
assert.NotNil(t, assistant.KB.Options)
assert.Equal(t, float64(5), assistant.KB.Options["top_k"])
})
t.Run("LoadMCPServers", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// MCP
assert.NotNil(t, assistant.MCP)
assert.NotNil(t, assistant.MCP.Servers)
assert.Len(t, assistant.MCP.Servers, 1)
assert.Equal(t, "echo", assistant.MCP.Servers[0].ServerID)
assert.Contains(t, assistant.MCP.Servers[0].Tools, "ping")
assert.Contains(t, assistant.MCP.Servers[0].Tools, "echo")
})
t.Run("LoadWorkflow", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// Workflow
assert.NotNil(t, assistant.Workflow)
assert.NotNil(t, assistant.Workflow.Workflows)
assert.Contains(t, assistant.Workflow.Workflows, "test-workflow")
assert.NotNil(t, assistant.Workflow.Options)
assert.Equal(t, float64(10), assistant.Workflow.Options["max_steps"])
})
t.Run("LoadPlaceholder", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// Placeholder
assert.NotNil(t, assistant.Placeholder)
assert.Equal(t, "Full Fields Test", assistant.Placeholder.Title)
assert.Equal(t, "Test assistant with complete field coverage", assistant.Placeholder.Description)
assert.NotNil(t, assistant.Placeholder.Prompts)
assert.Len(t, assistant.Placeholder.Prompts, 3)
})
t.Run("LoadLocales", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// Locales
assert.NotNil(t, assistant.Locales)
enLocale, hasEn := assistant.Locales["en-us"]
assert.True(t, hasEn, "Should have en-us locale")
assert.NotNil(t, enLocale)
zhLocale, hasZh := assistant.Locales["zh-cn"]
assert.True(t, hasZh, "Should have zh-cn locale")
assert.NotNil(t, zhLocale)
})
t.Run("LoadDependencies", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
require.NotNil(t, assistant)
// Dependencies
assert.NotNil(t, assistant.Dependencies)
assert.Len(t, assistant.Dependencies, 2)
assert.Equal(t, "^1.0.0", assistant.Dependencies["echo"])
assert.Equal(t, ">=2.0.0", assistant.Dependencies["customer"])
})
t.Run("LoadNonExistentAssistant", func(t *testing.T) {
_, err := assistant.LoadPath("/assistants/non-existent")
assert.Error(t, err)
})
}
// TestLoadPathMCPTest tests loading the MCP test assistant
func TestLoadPathMCPTest(t *testing.T) {
prepare(t)
defer test.Clean()
assistant, err := assistant.LoadPath("/assistants/tests/mcptest")
require.NoError(t, err)
require.NotNil(t, assistant)
assert.Equal(t, "tests.mcptest", assistant.ID)
assert.Equal(t, "MCP Test Assistant", assistant.Name)
assert.Equal(t, "gpt-4o", assistant.Connector)
// MCP configuration
assert.NotNil(t, assistant.MCP)
assert.Len(t, assistant.MCP.Servers, 1)
assert.Equal(t, "echo", assistant.MCP.Servers[0].ServerID)
// Locales
assert.NotNil(t, assistant.Locales)
assert.Contains(t, assistant.Locales, "en-us")
assert.Contains(t, assistant.Locales, "zh-cn")
}
// TestLoadPathBuildRequest tests loading the build request test assistant
func TestLoadPathBuildRequest(t *testing.T) {
prepare(t)
defer test.Clean()
assistant, err := assistant.LoadPath("/assistants/tests/buildrequest")
require.NoError(t, err)
require.NotNil(t, assistant)
assert.Equal(t, "tests.buildrequest", assistant.ID)
assert.Equal(t, "Build Request Test", assistant.Name)
// HookScript should be loaded
assert.NotNil(t, assistant.HookScript)
// Options
assert.NotNil(t, assistant.Options)
assert.Equal(t, 0.5, assistant.Options["temperature"])
}
// TestCache tests the assistant cache functionality
func TestCache(t *testing.T) {
// Clear any existing cache
assistant.ClearCache()
// Set small cache for testing
assistant.SetCache(3)
assert.NotNil(t, assistant.GetCache())
// Create test assistants
ast1 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id1", Name: "Assistant 1"}}
ast2 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id2", Name: "Assistant 2"}}
ast3 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id3", Name: "Assistant 3"}}
ast4 := &assistant.Assistant{AssistantModel: store.AssistantModel{ID: "id4", Name: "Assistant 4"}}
t.Run("PutAndGet", func(t *testing.T) {
assistant.GetCache().Put(ast1)
assert.Equal(t, 1, assistant.GetCache().Len())
cached, exists := assistant.GetCache().Get("id1")
assert.True(t, exists)
assert.Equal(t, ast1, cached)
})
t.Run("CacheEviction", func(t *testing.T) {
assistant.GetCache().Put(ast2)
assistant.GetCache().Put(ast3)
assert.Equal(t, 3, assistant.GetCache().Len())
// Access ast1 to make it recently used
assistant.GetCache().Get("id1")
// Add ast4, should evict ast2 (least recently used)
assistant.GetCache().Put(ast4)
assert.Equal(t, 3, assistant.GetCache().Len())
_, exists := assistant.GetCache().Get("id2")
assert.False(t, exists, "ast2 should be evicted")
_, exists = assistant.GetCache().Get("id1")
assert.True(t, exists, "ast1 should still exist")
_, exists = assistant.GetCache().Get("id4")
assert.True(t, exists, "ast4 should exist")
})
t.Run("ClearCache", func(t *testing.T) {
assistant.ClearCache()
assert.Nil(t, assistant.GetCache())
})
t.Run("SetCacheAfterClear", func(t *testing.T) {
assistant.SetCache(100)
assert.NotNil(t, assistant.GetCache())
})
}
// TestClone tests the assistant Clone method
func TestClone(t *testing.T) {
prepare(t)
defer test.Clean()
t.Run("CloneFullFieldsAssistant", func(t *testing.T) {
original, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
clone := original.Clone()
require.NotNil(t, clone)
// Basic fields should be equal
assert.Equal(t, original.ID, clone.ID)
assert.Equal(t, original.Name, clone.Name)
assert.Equal(t, original.Type, clone.Type)
assert.Equal(t, original.Connector, clone.Connector)
assert.Equal(t, original.Description, clone.Description)
// Verify deep copy - modifying original should not affect clone
if len(original.Tags) > 0 {
originalTag := original.Tags[0]
original.Tags[0] = "modified"
assert.NotEqual(t, original.Tags[0], clone.Tags[0])
original.Tags[0] = originalTag // restore
}
if original.Options != nil {
original.Options["test_key"] = "test_value"
_, exists := clone.Options["test_key"]
assert.False(t, exists, "Clone should not have modified key")
delete(original.Options, "test_key") // cleanup
}
if original.Dependencies != nil {
original.Dependencies["test_dep"] = "^9.9.9"
_, exists := clone.Dependencies["test_dep"]
assert.False(t, exists, "Clone dependencies should not have modified key")
delete(original.Dependencies, "test_dep") // cleanup
}
})
t.Run("CloneNil", func(t *testing.T) {
var nilAssistant *assistant.Assistant
assert.Nil(t, nilAssistant.Clone())
})
}
// TestUpdate tests the assistant Update method
func TestUpdate(t *testing.T) {
prepare(t)
defer test.Clean()
t.Run("UpdateBasicFields", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
updates := map[string]interface{}{
"name": "Updated Name",
"description": "Updated description",
"tags": []string{"updated", "tags"},
}
err = assistant.Update(updates)
require.NoError(t, err)
assert.Equal(t, "Updated Name", assistant.Name)
assert.Equal(t, "Updated description", assistant.Description)
assert.Equal(t, []string{"updated", "tags"}, assistant.Tags)
})
t.Run("UpdateConnectorOptions", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
updates := map[string]interface{}{
"connector_options": map[string]interface{}{
"optional": false,
"connectors": []string{"new-connector"},
},
}
err = assistant.Update(updates)
require.NoError(t, err)
assert.NotNil(t, assistant.ConnectorOptions)
assert.NotNil(t, assistant.ConnectorOptions.Optional)
assert.False(t, *assistant.ConnectorOptions.Optional)
assert.Contains(t, assistant.ConnectorOptions.Connectors, "new-connector")
})
t.Run("UpdatePromptPresets", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
updates := map[string]interface{}{
"prompt_presets": map[string]interface{}{
"custom": []map[string]interface{}{
{"role": "system", "content": "Custom preset"},
},
},
}
err = assistant.Update(updates)
require.NoError(t, err)
assert.NotNil(t, assistant.PromptPresets)
customPreset, exists := assistant.PromptPresets["custom"]
assert.True(t, exists)
assert.Len(t, customPreset, 1)
})
t.Run("UpdateSource", func(t *testing.T) {
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
updates := map[string]interface{}{
"source": "function Create(ctx, messages) { return { messages: messages }; }",
}
err = assistant.Update(updates)
require.NoError(t, err)
assert.Equal(t, "function Create(ctx, messages) { return { messages: messages }; }", assistant.Source)
})
t.Run("UpdateNilAssistant", func(t *testing.T) {
var nilAssistant *assistant.Assistant
err := nilAssistant.Update(map[string]interface{}{"name": "test"})
assert.Error(t, err)
})
}
// TestMap tests the assistant Map method
func TestMap(t *testing.T) {
prepare(t)
defer test.Clean()
assistant, err := assistant.LoadPath("/assistants/tests/fullfields")
require.NoError(t, err)
m := assistant.Map()
require.NotNil(t, m)
// Check all fields are present
assert.Equal(t, assistant.ID, m["assistant_id"])
assert.Equal(t, assistant.Name, m["name"])
assert.Equal(t, assistant.Type, m["type"])
assert.Equal(t, assistant.Connector, m["connector"])
assert.Equal(t, assistant.Description, m["description"])
assert.Equal(t, assistant.Path, m["path"])
assert.Equal(t, assistant.Tags, m["tags"])
assert.Equal(t, assistant.Options, m["options"])
assert.Equal(t, assistant.Prompts, m["prompts"])
assert.Equal(t, assistant.KB, m["kb"])
assert.Equal(t, assistant.MCP, m["mcp"])
assert.Equal(t, assistant.Workflow, m["workflow"])
assert.Equal(t, assistant.Placeholder, m["placeholder"])
assert.Equal(t, assistant.Locales, m["locales"])
// New fields
assert.Equal(t, assistant.ConnectorOptions, m["connector_options"])
assert.Equal(t, assistant.PromptPresets, m["prompt_presets"])
assert.Equal(t, assistant.Source, m["source"])
assert.Equal(t, assistant.Dependencies, m["dependencies"])
}
// TestLoadSystemAgents tests loading system agents from bindata
func TestLoadSystemAgents(t *testing.T) {
prepareAgent(t)
defer test.Clean()
// Clear cache first
assistant.ClearCache()
assistant.SetCache(200)
t.Run("LoadSystemAgents", func(t *testing.T) {
err := assistant.LoadSystemAgents()
require.NoError(t, err)
// Check __yao.keyword
keywordAst, keywordExists := assistant.GetCache().Get("__yao.keyword")
require.True(t, keywordExists, "__yao.keyword should be loaded")
assert.Equal(t, "__yao.keyword", keywordAst.ID)
assert.Equal(t, "Keyword Extractor", keywordAst.Name)
assert.True(t, keywordAst.Readonly)
assert.True(t, keywordAst.BuiltIn)
assert.Contains(t, keywordAst.Tags, "system")
assert.NotNil(t, keywordAst.Prompts)
assert.Greater(t, len(keywordAst.Prompts), 0)
// Check __yao.querydsl
querydslAst, querydslExists := assistant.GetCache().Get("__yao.querydsl")
require.True(t, querydslExists, "__yao.querydsl should be loaded")
assert.Equal(t, "__yao.querydsl", querydslAst.ID)
assert.Equal(t, "Query Builder", querydslAst.Name)
assert.True(t, querydslAst.Readonly)
assert.True(t, querydslAst.BuiltIn)
assert.Contains(t, querydslAst.Tags, "system")
assert.NotNil(t, querydslAst.Prompts)
assert.Greater(t, len(querydslAst.Prompts), 0)
// Check __yao.title
titleAst, titleExists := assistant.GetCache().Get("__yao.title")
require.True(t, titleExists, "__yao.title should be loaded")
assert.Equal(t, "__yao.title", titleAst.ID)
assert.Equal(t, "Title Generator", titleAst.Name)
assert.True(t, titleAst.Readonly)
assert.True(t, titleAst.BuiltIn)
// Check __yao.prompt
promptAst, promptExists := assistant.GetCache().Get("__yao.prompt")
require.True(t, promptExists, "__yao.prompt should be loaded")
assert.Equal(t, "__yao.prompt", promptAst.ID)
assert.Equal(t, "Prompt Optimizer", promptAst.Name)
assert.True(t, promptAst.Readonly)
assert.True(t, promptAst.BuiltIn)
// Check __yao.needsearch
needsearchAst, needsearchExists := assistant.GetCache().Get("__yao.needsearch")
require.True(t, needsearchExists, "__yao.needsearch should be loaded")
assert.Equal(t, "__yao.needsearch", needsearchAst.ID)
assert.Equal(t, "Reference Checker", needsearchAst.Name)
assert.True(t, needsearchAst.Readonly)
assert.True(t, needsearchAst.BuiltIn)
})
t.Run("SystemAgentsSavedToStorage", func(t *testing.T) {
// System agents should be saved to storage
require.NotNil(t, assistant.GetStore(), "storage should be initialized")
// Check __yao.keyword in storage
builtIn := true
tags := []string{"system"}
res, err := assistant.GetStore().GetAssistants(store.AssistantFilter{
BuiltIn: &builtIn,
Tags: tags,
Select: []string{"assistant_id", "name"},
})
require.NoError(t, err)
require.Greater(t, len(res.Data), 0, "System agents should be in storage")
// Verify at least one system agent exists
found := false
for _, ast := range res.Data {
if ast.ID == "__yao.keyword" || ast.ID == "__yao.querydsl" {
found = true
break
}
}
assert.True(t, found, "System agents should be found in storage")
})
t.Run("SystemAgentsGetFromStorage", func(t *testing.T) {
// Clear cache to force loading from storage
assistant.GetCache().Clear()
// Test Get for each system agent
systemAgents := []string{
"__yao.keyword",
"__yao.querydsl",
"__yao.title",
"__yao.prompt",
"__yao.needsearch",
"__yao.entity",
}
for _, agentID := range systemAgents {
ast, err := assistant.Get(agentID)
require.NoError(t, err, "Get(%s) should succeed", agentID)
require.NotNil(t, ast, "Get(%s) should return assistant", agentID)
assert.Equal(t, agentID, ast.ID)
assert.True(t, ast.BuiltIn, "%s should be built-in", agentID)
assert.True(t, ast.Readonly, "%s should be readonly", agentID)
assert.Contains(t, ast.Tags, "system", "%s should have system tag", agentID)
assert.Equal(t, "worker", ast.Type, "%s should be worker type", agentID)
assert.NotNil(t, ast.Prompts, "%s should have prompts", agentID)
assert.Greater(t, len(ast.Prompts), 0, "%s should have at least one prompt", agentID)
}
})
}
// TestLoadPathSandboxV2 tests loading assistants with V2 sandbox configuration (standalone sandbox.yao)
func TestLoadPathSandboxV2(t *testing.T) {
prepare(t)
defer test.Clean()
t.Run("OneshotCLI", func(t *testing.T) {
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
require.NoError(t, err)
require.NotNil(t, ast)
assert.Equal(t, "Sandbox V2 Oneshot CLI", ast.Name)
assert.Contains(t, ast.Tags, "SandboxV2")
// V2 sandbox should be loaded from sandbox.yao
require.NotNil(t, ast.SandboxV2, "SandboxV2 should be loaded")
assert.Equal(t, "2.0", ast.SandboxV2.Version)
assert.Equal(t, "yaoapp/tai-sandbox-claude:latest", ast.SandboxV2.Computer.Image)
assert.Equal(t, "2GB", ast.SandboxV2.Computer.Memory)
assert.Equal(t, float64(2), ast.SandboxV2.Computer.CPUs)
assert.Equal(t, "/workspace", ast.SandboxV2.Computer.WorkDir)
assert.Equal(t, "claude", ast.SandboxV2.Runner.Name)
assert.Equal(t, "cli", ast.SandboxV2.Runner.Mode)
assert.Equal(t, "oneshot", ast.SandboxV2.Lifecycle)
// Runner options
assert.NotNil(t, ast.SandboxV2.Runner.Options)
assert.Equal(t, float64(5), ast.SandboxV2.Runner.Options["max_turns"])
// V1 Sandbox should be nil
assert.Nil(t, ast.Sandbox, "V1 Sandbox should be nil when V2 is present")
// ConfigHash should be computed
assert.NotEmpty(t, ast.ConfigHash, "ConfigHash should be computed for V2 sandbox")
// HasSandboxV2 helper
assert.True(t, ast.HasSandboxV2())
})
t.Run("SessionCLI", func(t *testing.T) {
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/session-cli")
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.SandboxV2)
assert.Equal(t, "session", ast.SandboxV2.Lifecycle)
assert.Equal(t, "10m", ast.SandboxV2.IdleTimeout)
// Prepare steps
require.Len(t, ast.SandboxV2.Prepare, 1)
assert.Equal(t, "exec", ast.SandboxV2.Prepare[0].Action)
assert.True(t, ast.SandboxV2.Prepare[0].Once)
})
t.Run("LongrunningCLI", func(t *testing.T) {
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/longrunning-cli")
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.SandboxV2)
assert.Equal(t, "longrunning", ast.SandboxV2.Lifecycle)
assert.Equal(t, "15m", ast.SandboxV2.IdleTimeout)
assert.Equal(t, "2h", ast.SandboxV2.MaxLifetime)
assert.Equal(t, "5s", ast.SandboxV2.StopTimeout)
assert.Equal(t, "4GB", ast.SandboxV2.Computer.Memory)
assert.Equal(t, "rw", ast.SandboxV2.Computer.MountMode)
// Environment
assert.Equal(t, "test", ast.SandboxV2.Environment["NODE_ENV"])
assert.Equal(t, "longrunning", ast.SandboxV2.Environment["V2_TEST_MODE"])
// Secrets
assert.Equal(t, "sandbox-v2-longrunning-secret", ast.SandboxV2.Secrets["TEST_SECRET"])
// Prepare steps
require.Len(t, ast.SandboxV2.Prepare, 3)
assert.True(t, ast.SandboxV2.Prepare[2].IgnoreError)
// MCP (from package.yao)
require.NotNil(t, ast.MCP)
require.Len(t, ast.MCP.Servers, 1)
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
// ConfigHash should include MCP servers
hashWithMCP := ast.ConfigHash
assert.NotEmpty(t, hashWithMCP)
})
t.Run("HooksOnly_YaoRunner", func(t *testing.T) {
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/hooks-only")
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.SandboxV2)
assert.Equal(t, "yao", ast.SandboxV2.Runner.Name)
assert.Equal(t, "oneshot", ast.SandboxV2.Lifecycle)
assert.Equal(t, float64(1), ast.SandboxV2.Computer.CPUs)
// Runner mode should be empty (yao runner ignores mode)
assert.Empty(t, ast.SandboxV2.Runner.Mode)
})
t.Run("FullPrepare", func(t *testing.T) {
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/full-prepare")
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.SandboxV2)
assert.Equal(t, "session", ast.SandboxV2.Lifecycle)
assert.Equal(t, "15m", ast.SandboxV2.IdleTimeout)
// Prepare: 5 steps with mixed actions
require.Len(t, ast.SandboxV2.Prepare, 5)
assert.Equal(t, "copy", ast.SandboxV2.Prepare[0].Action)
assert.Equal(t, "skills", ast.SandboxV2.Prepare[0].Src)
assert.Equal(t, "~/.claude/skills", ast.SandboxV2.Prepare[0].Dst)
assert.Equal(t, "exec", ast.SandboxV2.Prepare[1].Action)
assert.True(t, ast.SandboxV2.Prepare[1].Once)
assert.True(t, ast.SandboxV2.Prepare[3].IgnoreError)
// Environment + Secrets
assert.Equal(t, "full", ast.SandboxV2.Environment["V2_PREPARE_TEST"])
assert.Equal(t, "v2-full-prepare-key", ast.SandboxV2.Secrets["TEST_API_KEY"])
// Runner options
assert.Equal(t, "acceptEdits", ast.SandboxV2.Runner.Options["permission_mode"])
})
t.Run("HostMode", func(t *testing.T) {
ast, err := assistant.LoadPath("/assistants/tests/sandbox-v2/host-mode")
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.SandboxV2)
// Host mode: no image
assert.Empty(t, ast.SandboxV2.Computer.Image)
assert.Equal(t, "/tmp/yao-sandbox-v2-host-test", ast.SandboxV2.Computer.WorkDir)
assert.Equal(t, "session", ast.SandboxV2.Lifecycle)
})
t.Run("ConfigHashDeterministic", func(t *testing.T) {
ast1, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
require.NoError(t, err)
ast2, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
require.NoError(t, err)
assert.Equal(t, ast1.ConfigHash, ast2.ConfigHash, "same config should produce same hash")
})
t.Run("ConfigHashDiffers", func(t *testing.T) {
ast1, err := assistant.LoadPath("/assistants/tests/sandbox-v2/oneshot-cli")
require.NoError(t, err)
ast2, err := assistant.LoadPath("/assistants/tests/sandbox-v2/longrunning-cli")
require.NoError(t, err)
assert.NotEqual(t, ast1.ConfigHash, ast2.ConfigHash, "different configs should produce different hashes")
})
}
// TestValidate tests the assistant Validate method
func TestValidate(t *testing.T) {
tests := []struct {
name string
ast *assistant.Assistant
wantErr bool
}{
{
name: "ValidAssistant",
ast: &assistant.Assistant{
AssistantModel: store.AssistantModel{
ID: "test-id",
Name: "Test Assistant",
Connector: "gpt-4o",
},
},
wantErr: false,
},
{
name: "MissingID",
ast: &assistant.Assistant{
AssistantModel: store.AssistantModel{
Name: "Test Assistant",
Connector: "gpt-4o",
},
},
wantErr: true,
},
{
name: "MissingName",
ast: &assistant.Assistant{
AssistantModel: store.AssistantModel{
ID: "test-id",
Connector: "gpt-4o",
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.ast.Validate()
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}

View file

@ -1,295 +0,0 @@
package assistant
import (
"fmt"
"strings"
jsoniter "github.com/json-iterator/go"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/trace/types"
)
// ToolLoopParams holds all parameters needed by executeToolLoop.
type ToolLoopParams struct {
CompletionMessages []context.Message
CompletionOptions *context.CompletionOptions
CompletionResponse *context.CompletionResponse
ToolCallResponses []context.ToolCallResponse
FullMessages []context.Message
AgentNode types.Node
StreamHandler message.StreamFunc
CreateResponse *context.HookCreateResponse
Opts *context.Options
}
// executeToolLoop feeds tool results back to the LLM in a loop until
// the LLM produces a final text response (no more tool_calls) or
// the maximum number of turns is reached.
//
// Returns the final Response, the last CompletionResponse (for tracing),
// accumulated ToolCallResponses, and any error.
func (ast *Assistant) executeToolLoop(
ctx *context.Context,
params *ToolLoopParams,
) (*context.Response, *context.CompletionResponse, []context.ToolCallResponse, error) {
maxTurns := ast.getMaxToolLoopTurns()
currentMessages := params.CompletionMessages
currentCompletion := params.CompletionResponse
allToolResponses := make([]context.ToolCallResponse, 0, len(params.ToolCallResponses))
allToolResponses = append(allToolResponses, params.ToolCallResponses...)
for turn := 0; turn < maxTurns; turn++ {
ctx.Logger.Debug("Tool loop turn %d/%d", turn+1, maxTurns)
// Build messages: previous messages + assistant(tool_calls) + tool results
loopMessages := buildToolLoopMessages(currentMessages, currentCompletion, allToolResponses[len(allToolResponses)-len(params.ToolCallResponses):])
// Step tracking: LLM call
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
"messages": loopMessages,
"loop_turn": turn + 1,
})
// Call LLM with tool results included
newCompletion, err := ast.executeLLMStream(ctx, loopMessages, params.CompletionOptions, params.AgentNode, params.StreamHandler, params.Opts)
if err != nil {
return nil, nil, nil, fmt.Errorf("tool loop LLM call failed (turn %d): %w", turn+1, err)
}
ast.CompleteStep(ctx, map[string]interface{}{
"content": newCompletion.Content,
"tool_calls": newCompletion.ToolCalls,
})
// No tool_calls → LLM gave final text response
if newCompletion.ToolCalls == nil || len(newCompletion.ToolCalls) == 0 {
finalResponse := ast.buildStandardResponse(&NextProcessContext{
Context: ctx,
CompletionResponse: newCompletion,
FullMessages: params.FullMessages,
ToolCallResponses: allToolResponses,
StreamHandler: params.StreamHandler,
CreateResponse: params.CreateResponse,
})
return finalResponse, newCompletion, allToolResponses, nil
}
// Has tool_calls → execute them
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
"tool_calls": newCompletion.ToolCalls,
"loop_turn": turn + 1,
})
toolResults, _ := ast.executeToolCalls(ctx, newCompletion.ToolCalls, 0)
// Convert ToolCallResult → ToolCallResponse
toolCallArgsMap := make(map[string]interface{})
for _, tc := range newCompletion.ToolCalls {
toolCallArgsMap[tc.ID] = tc.Function.Arguments
}
turnResponses := make([]context.ToolCallResponse, len(toolResults))
for i, result := range toolResults {
parsedContent, _ := result.ParsedContent()
turnResponses[i] = context.ToolCallResponse{
ToolCallID: result.ToolCallID,
Server: result.Server(),
Tool: result.Tool(),
Arguments: toolCallArgsMap[result.ToolCallID],
Result: parsedContent,
Error: "",
}
if result.Error != nil {
turnResponses[i].Error = result.Error.Error()
}
}
ast.CompleteStep(ctx, map[string]interface{}{
"results": turnResponses,
"loop_turn": turn + 1,
})
// Accumulate and prepare next iteration
allToolResponses = append(allToolResponses, turnResponses...)
currentMessages = loopMessages
currentCompletion = newCompletion
params.ToolCallResponses = turnResponses
}
return nil, nil, allToolResponses, fmt.Errorf("tool loop reached max turns (%d)", maxTurns)
}
// buildToolLoopMessages constructs the message sequence for the next LLM call:
// previous messages + assistant message (with tool_calls) + tool result messages.
// Unlike buildToolRetryMessages, this does NOT append a retry system prompt.
func buildToolLoopMessages(
previousMessages []context.Message,
completion *context.CompletionResponse,
toolResponses []context.ToolCallResponse,
) []context.Message {
messages := make([]context.Message, 0, len(previousMessages)+len(toolResponses)+2)
messages = append(messages, previousMessages...)
// Assistant message with tool_calls
messages = append(messages, context.Message{
Role: context.RoleAssistant,
Content: completion.Content,
ReasoningContent: completion.ReasoningContent,
ToolCalls: completion.ToolCalls,
})
// One tool-role message per tool call result
for _, tr := range toolResponses {
var content string
if tr.Error != "" {
content = fmt.Sprintf("Error: %s", tr.Error)
} else if tr.Result != nil {
raw, _ := jsoniter.MarshalToString(tr.Result)
content = raw
}
toolCallID := tr.ToolCallID
messages = append(messages, context.Message{
Role: context.RoleTool,
Content: content,
ToolCallID: &toolCallID,
})
}
return messages
}
// isToolLoopDisabled checks mcp.options.tool_loop.
// Default is enabled (returns false). Only disabled when explicitly set to false.
func (ast *Assistant) isToolLoopDisabled() bool {
if ast.MCP == nil || ast.MCP.Options == nil {
return false
}
if v, ok := ast.MCP.Options["tool_loop"]; ok {
if enabled, ok := v.(bool); ok {
return !enabled
}
}
return false
}
// getMaxToolLoopTurns reads mcp.options.max_turn. Default is 5.
func (ast *Assistant) getMaxToolLoopTurns() int {
const defaultMaxTurns = 5
if ast.MCP == nil || ast.MCP.Options == nil {
return defaultMaxTurns
}
if v, ok := ast.MCP.Options["max_turn"]; ok {
switch n := v.(type) {
case float64:
if n > 0 {
return int(n)
}
case int:
if n > 0 {
return n
}
}
}
return defaultMaxTurns
}
// ---------------------------------------------------------------------------
// Fallback: __yao.loop_fallback delegation (used when tool loop fails/maxes out)
// ---------------------------------------------------------------------------
// buildLoopFallbackDelegate constructs a DelegateConfig for __yao.loop_fallback.
// It packages conversation context and tool results into a Markdown user message.
func (ast *Assistant) buildLoopFallbackDelegate(
ctx *context.Context,
fullMessages []context.Message,
completion *context.CompletionResponse,
toolResults []context.ToolCallResponse,
) *context.DelegateConfig {
content := buildLoopFallbackMarkdown(fullMessages, toolResults)
return &context.DelegateConfig{
AgentID: "__yao.loop_fallback",
Messages: []context.Message{
{Role: context.RoleUser, Content: content},
},
}
}
// buildLoopFallbackMarkdown formats context into a Markdown string for the fallback agent.
func buildLoopFallbackMarkdown(
fullMessages []context.Message,
toolResults []context.ToolCallResponse,
) string {
var sb strings.Builder
sb.WriteString("## Assistant Context\n\n")
for _, msg := range fullMessages {
if msg.Role == context.RoleSystem {
if text := messageText(msg); text != "" {
sb.WriteString(text)
sb.WriteString("\n\n")
}
}
}
sb.WriteString("## Conversation\n\n")
for _, msg := range fullMessages {
text := messageText(msg)
switch msg.Role {
case context.RoleUser:
if text != "" {
sb.WriteString(fmt.Sprintf("**User**: %s\n\n", text))
}
case context.RoleAssistant:
if text != "" {
sb.WriteString(fmt.Sprintf("**Assistant**: %s\n\n", text))
}
}
}
sb.WriteString("## Tool Results\n\n")
for _, tr := range toolResults {
toolName := tr.Tool
if tr.Server != "" {
toolName = tr.Server + "." + tr.Tool
}
sb.WriteString(fmt.Sprintf("### %s\n", toolName))
if tr.Error != "" {
sb.WriteString(fmt.Sprintf("Error: %s\n\n", tr.Error))
} else {
raw, _ := jsoniter.MarshalToString(tr.Result)
sb.WriteString(fmt.Sprintf("```json\n%s\n```\n\n", raw))
}
}
sb.WriteString("---\nPlease answer the user's question based on the above context and tool results.\n")
sb.WriteString("Respond in the same language as the user.\n")
return sb.String()
}
// messageText extracts text content from a message's Content field.
// Content can be a string or an array of content parts (multimodal).
func messageText(msg context.Message) string {
if msg.Content == nil {
return ""
}
if str, ok := msg.Content.(string); ok {
return str
}
if parts, ok := msg.Content.([]interface{}); ok {
var texts []string
for _, part := range parts {
if partMap, ok := part.(map[string]interface{}); ok {
if partMap["type"] == "text" {
if text, ok := partMap["text"].(string); ok {
texts = append(texts, text)
}
}
}
}
return strings.Join(texts, "\n")
}
return fmt.Sprintf("%v", msg.Content)
}

View file

@ -1,856 +0,0 @@
package assistant
import (
"context"
"fmt"
"strings"
jsoniter "github.com/json-iterator/go"
gouJson "github.com/yaoapp/gou/json"
"github.com/yaoapp/gou/mcp"
mcpTypes "github.com/yaoapp/gou/mcp/types"
agentContext "github.com/yaoapp/yao/agent/context"
storeTypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/trace/types"
)
const (
// MaxMCPTools maximum number of MCP tools to include (to avoid overwhelming the LLM)
MaxMCPTools = 20
)
// MCPToolName formats a tool name with MCP server prefix
// Format: server_id__tool_name (double underscore separator)
// Dots in server_id are replaced with single underscores
// Examples:
// - ("echo", "ping") → "echo__ping"
// - ("github.enterprise", "search") → "github_enterprise__search"
//
// Naming constraint: MCP server_id MUST NOT contain underscores (_)
// Only dots (.), letters, numbers, and hyphens (-) are allowed in server_id
func MCPToolName(serverID, toolName string) string {
if serverID == "" || toolName == "" {
return ""
}
// Replace dots with single underscores in server_id
cleanServerID := strings.ReplaceAll(serverID, ".", "_")
// Use double underscore as separator
return fmt.Sprintf("%s__%s", cleanServerID, toolName)
}
// ParseMCPToolName parses a formatted MCP tool name into server ID and tool name
// Splits by double underscore (__), then restores dots in server_id
// Examples:
// - "echo__ping" → ("echo", "ping")
// - "github_enterprise__search" → ("github.enterprise", "search")
//
// Returns (serverID, toolName, true) if valid format, ("", "", false) otherwise
func ParseMCPToolName(formattedName string) (string, string, bool) {
if formattedName == "" {
return "", "", false
}
// Split by double underscore
parts := strings.Split(formattedName, "__")
if len(parts) != 2 {
return "", "", false
}
cleanServerID := parts[0]
toolName := parts[1]
// Validate that both parts are non-empty
if cleanServerID == "" || toolName == "" {
return "", "", false
}
// Restore dots in server_id (replace single underscores back to dots)
serverID := strings.ReplaceAll(cleanServerID, "_", ".")
return serverID, toolName, true
}
// buildMCPTools builds tool definitions and samples system prompt from MCP servers
// Returns (tools, samplesPrompt, error)
func (ast *Assistant) buildMCPTools(ctx *agentContext.Context, createResponse *agentContext.HookCreateResponse) ([]MCPTool, string, error) {
// Determine which MCP servers to use: hook's or assistant's (hook takes precedence)
var servers []storeTypes.MCPServerConfig
// If hook provides MCP servers, use those (override)
if createResponse != nil && len(createResponse.MCPServers) > 0 {
servers = make([]storeTypes.MCPServerConfig, len(createResponse.MCPServers))
for i, hookServer := range createResponse.MCPServers {
// Convert context.MCPServerConfig to storeTypes.MCPServerConfig
servers[i] = storeTypes.MCPServerConfig{
ServerID: hookServer.ServerID,
Tools: hookServer.Tools,
Resources: hookServer.Resources,
}
}
} else if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
// Otherwise, use assistant's configured servers
servers = ast.MCP.Servers
} else {
// No servers configured
return nil, "", nil
}
// Use the agent context for cancellation and timeout control
mcpCtx := ctx.Context
if mcpCtx == nil {
mcpCtx = context.Background()
}
allTools := make([]MCPTool, 0)
samplesBuilder := strings.Builder{}
hasSamples := false
// Process each MCP server in order
for _, serverConfig := range servers {
if len(allTools) >= MaxMCPTools {
ctx.Logger.Warn("Reached maximum tool limit (%d), skipping remaining servers", MaxMCPTools)
break
}
// Get MCP client
client, err := mcp.Select(serverConfig.ServerID)
if err != nil {
ctx.Logger.Warn("Failed to select MCP client '%s': %v", serverConfig.ServerID, err)
continue
}
// Get tools list (filter by serverConfig.Tools if specified)
toolsResponse, err := client.ListTools(mcpCtx, "")
if err != nil {
ctx.Logger.Warn("Failed to list tools for '%s': %v", serverConfig.ServerID, err)
continue
}
// Build tool filter map if specified
toolFilter := make(map[string]bool)
if len(serverConfig.Tools) > 0 {
for _, toolName := range serverConfig.Tools {
toolFilter[toolName] = true
}
}
// Process each tool
for _, tool := range toolsResponse.Tools {
// Check tool limit
if len(allTools) >= MaxMCPTools {
break
}
// Apply tool filter if specified
if len(toolFilter) > 0 && !toolFilter[tool.Name] {
continue
}
// Format tool name with server prefix
formattedName := MCPToolName(serverConfig.ServerID, tool.Name)
// Convert MCP tool to MCPTool format
mcpTool := MCPTool{
Name: formattedName,
Description: tool.Description,
Parameters: tool.InputSchema,
}
allTools = append(allTools, mcpTool)
// Try to get samples for this tool
samples, err := client.ListSamples(mcpCtx, mcpTypes.SampleTool, tool.Name)
if err == nil && len(samples.Samples) > 0 {
if !hasSamples {
samplesBuilder.WriteString("\n\n## MCP Tool Usage Examples\n\n")
samplesBuilder.WriteString("The following examples demonstrate how to use MCP tools correctly:\n\n")
hasSamples = true
}
samplesBuilder.WriteString(fmt.Sprintf("### %s\n\n", formattedName))
if tool.Description != "" {
samplesBuilder.WriteString(fmt.Sprintf("**Description**: %s\n\n", tool.Description))
}
for i, sample := range samples.Samples {
if i >= 3 { // Limit to 3 examples per tool
break
}
samplesBuilder.WriteString(fmt.Sprintf("**Example %d", i+1))
if sample.Name != "" {
samplesBuilder.WriteString(fmt.Sprintf(" - %s", sample.Name))
}
samplesBuilder.WriteString("**:\n")
// Check metadata for description
if sample.Metadata != nil {
if desc, ok := sample.Metadata["description"].(string); ok && desc != "" {
samplesBuilder.WriteString(fmt.Sprintf("- Description: %s\n", desc))
}
}
if sample.Input != nil {
samplesBuilder.WriteString(fmt.Sprintf("- Input: `%v`\n", sample.Input))
}
if sample.Output != nil {
samplesBuilder.WriteString(fmt.Sprintf("- Output: `%v`\n", sample.Output))
}
samplesBuilder.WriteString("\n")
}
}
}
ctx.Logger.Debug("Loaded %d tools from server '%s'", len(toolsResponse.Tools), serverConfig.ServerID)
}
samplesPrompt := ""
if hasSamples {
samplesPrompt = samplesBuilder.String()
}
ctx.Logger.Debug("Total MCP tools loaded: %d", len(allTools))
return allTools, samplesPrompt, nil
}
// ToolCallResult represents the result of a tool call execution
// executeToolCalls executes tool calls with intelligent strategy and trace logging:
// - Single tool: use CallTool, single trace node
// - Multiple tools: use CallToolsParallel with parallel trace nodes, fallback to sequential on certain errors
// Returns (results, hasErrors)
func (ast *Assistant) executeToolCalls(ctx *agentContext.Context, toolCalls []agentContext.ToolCall, attempt int) ([]ToolCallResult, bool) {
if len(toolCalls) == 0 {
return nil, false
}
ctx.Logger.Debug("Executing %d tool calls (attempt %d)", len(toolCalls), attempt)
// Single tool call
if len(toolCalls) == 1 {
return ast.executeSingleToolCall(ctx, toolCalls[0])
}
// Multiple tool calls - try parallel first
return ast.executeMultipleToolCallsParallel(ctx, toolCalls)
}
// executeSingleToolCall executes a single tool call with trace logging
func (ast *Assistant) executeSingleToolCall(ctx *agentContext.Context, toolCall agentContext.ToolCall) ([]ToolCallResult, bool) {
ctx.Logger.ToolStart(toolCall.Function.Name)
trace, _ := ctx.Trace()
// Use the agent context for cancellation and timeout control
mcpCtx := ctx.Context
if mcpCtx == nil {
mcpCtx = context.Background()
}
result := ToolCallResult{
ToolCallID: toolCall.ID,
Name: toolCall.Function.Name,
}
// Parse tool name
serverID, toolName, ok := ParseMCPToolName(toolCall.Function.Name)
if !ok {
result.Error = fmt.Errorf("invalid MCP tool name format: %s", toolCall.Function.Name)
result.Content = result.Error.Error()
ctx.Logger.Error("Invalid MCP tool name format: %s", toolCall.Function.Name)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
return []ToolCallResult{result}, true
}
// Get MCP client
client, err := mcp.Select(serverID)
if err != nil {
result.Error = fmt.Errorf("failed to select MCP client '%s': %w", serverID, err)
result.Content = result.Error.Error()
result.IsRetryableError = false // MCP client selection error is not retryable
ctx.Logger.Error("Failed to select MCP client '%s': %v", serverID, err)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
return []ToolCallResult{result}, true
}
// Get tool info for description and schema
toolsResponse, err := client.ListTools(mcpCtx, "")
var toolDescription string
var toolSchema interface{}
if err == nil {
for _, t := range toolsResponse.Tools {
if t.Name == toolName {
toolDescription = t.Description
toolSchema = t.InputSchema
break
}
}
}
if toolDescription == "" {
toolDescription = fmt.Sprintf("MCP tool '%s'", toolName)
}
// Add trace node for this tool call
var toolNode types.Node
if trace != nil {
toolNode, _ = trace.Add(
map[string]any{
"tool_call_id": toolCall.ID,
"server": serverID,
"tool": toolName,
"arguments": toolCall.Function.Arguments,
},
types.TraceNodeOption{
Label: toolDescription,
Type: "mcp_tool",
Icon: "build",
Description: fmt.Sprintf("Calling '%s' on server '%s'", toolName, serverID),
},
)
}
// Parse arguments with repair support for better tolerance
var args map[string]interface{}
if toolCall.Function.Arguments != "" {
parsed, err := gouJson.Parse(toolCall.Function.Arguments)
if err != nil {
result.Error = fmt.Errorf("failed to parse arguments: %w", err)
result.Content = result.Error.Error()
result.IsRetryableError = true // Argument parsing error is retryable by LLM
ctx.Logger.Error("Failed to parse arguments: %v", err)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
// Convert to map
if argsMap, ok := parsed.(map[string]interface{}); ok {
args = argsMap
} else {
result.Error = fmt.Errorf("arguments must be an object, got %T", parsed)
result.Content = result.Error.Error()
result.IsRetryableError = true // Type error is retryable by LLM
ctx.Logger.Error("Arguments must be an object, got %T", parsed)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
// Validate arguments against tool schema if available
if toolSchema != nil {
if err := gouJson.Validate(args, toolSchema); err != nil {
result.Error = fmt.Errorf("argument validation failed: %w", err)
result.Content = result.Error.Error()
result.IsRetryableError = true // Validation error is retryable by LLM
ctx.Logger.Error("Argument validation failed: %v", err)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
}
}
// Call the tool with agent context as extra argument
ctx.Logger.Debug("Calling tool: %s (server: %s)", toolName, serverID)
// Pass agent context as extra argument (only used for Process transport)
callResult, err := client.CallTool(mcpCtx, toolName, args, ctx)
if err != nil {
result.Error = fmt.Errorf("tool call failed: %w", err)
result.Content = result.Error.Error()
// Check if error is retryable (parameter/validation errors)
result.IsRetryableError = isRetryableToolError(err)
ctx.Logger.Error("Tool call failed: %v (retryable: %v)", err, result.IsRetryableError)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
// Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(callResult.Content)
if err != nil {
result.Error = fmt.Errorf("failed to serialize result: %w", err)
result.Content = result.Error.Error()
result.IsRetryableError = false
ctx.Logger.Error("Failed to serialize result: %v", err)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
result.Content = string(contentBytes)
// Check if result is an error — include actual content so LLM can see the details
if callResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolCall.Function.Name, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(toolCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
return []ToolCallResult{result}, true
}
ctx.Logger.ToolComplete(toolCall.Function.Name, true)
if toolNode != nil {
toolNode.Complete(map[string]any{
"result": callResult,
})
}
return []ToolCallResult{result}, false
}
// executeMultipleToolCallsParallel executes multiple tool calls in parallel with trace logging
func (ast *Assistant) executeMultipleToolCallsParallel(ctx *agentContext.Context, toolCalls []agentContext.ToolCall) ([]ToolCallResult, bool) {
trace, _ := ctx.Trace()
// Use the agent context for cancellation and timeout control
mcpCtx := ctx.Context
if mcpCtx == nil {
mcpCtx = context.Background()
}
// Group tool calls by server
serverGroups := make(map[string][]agentContext.ToolCall)
for _, tc := range toolCalls {
serverID, _, ok := ParseMCPToolName(tc.Function.Name)
if !ok {
ctx.Logger.Warn("Invalid tool name format: %s", tc.Function.Name)
continue
}
serverGroups[serverID] = append(serverGroups[serverID], tc)
}
results := make([]ToolCallResult, 0, len(toolCalls))
hasErrors := false
// Process each server's tools
for serverID, calls := range serverGroups {
client, err := mcp.Select(serverID)
if err != nil {
ctx.Logger.Error("Failed to select MCP client '%s': %v", serverID, err)
// Add error results for all calls to this server
for _, tc := range calls {
results = append(results, ToolCallResult{
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: fmt.Sprintf("Failed to select MCP client: %v", err),
Error: err,
})
}
hasErrors = true
continue
}
// Try parallel execution
serverResults, serverHasErrors := ast.executeServerToolsParallelWithTrace(
mcpCtx, ctx, trace, client, serverID, calls,
)
// If parallel execution failed with retryable error, try sequential
if serverHasErrors && ast.shouldRetrySequential(serverResults) {
ctx.Logger.Warn("Parallel execution had parameter errors for server '%s', retrying sequentially", serverID)
serverResults, serverHasErrors = ast.executeServerToolsSequentialWithTrace(
mcpCtx, ctx, trace, client, serverID, calls,
)
}
results = append(results, serverResults...)
if serverHasErrors {
hasErrors = true
}
}
return results, hasErrors
}
// isRetryableToolError checks if an error is retryable by LLM (parameter/validation errors)
// Returns true for errors that LLM can potentially fix by adjusting parameters
// Returns false for MCP internal errors (network, auth, service unavailable, etc.)
func isRetryableToolError(err error) bool {
if err == nil {
return false
}
errMsg := strings.ToLower(err.Error())
// These are NOT retryable (MCP internal issues)
nonRetryablePatterns := []string{
"network",
"timeout",
"connection",
"unauthorized",
"forbidden",
"unavailable",
"failed to select",
"context canceled",
"context deadline",
"server error",
"internal error",
}
for _, pattern := range nonRetryablePatterns {
if strings.Contains(errMsg, pattern) {
return false
}
}
// These ARE retryable (parameter/validation issues LLM can fix)
retryablePatterns := []string{
"invalid",
"required",
"missing",
"validation",
"schema",
"type",
"format",
"parse",
"argument",
"parameter",
}
for _, pattern := range retryablePatterns {
if strings.Contains(errMsg, pattern) {
return true
}
}
// Default: assume it's retryable unless proven otherwise
// This allows LLM to attempt fixes for unknown error types
return true
}
// shouldRetrySequential checks if errors are retryable (parameter issues, not network/service issues)
func (ast *Assistant) shouldRetrySequential(results []ToolCallResult) bool {
// Check if any result has a retryable error
hasRetryable := false
for _, result := range results {
if result.Error != nil && result.IsRetryableError {
hasRetryable = true
break
}
}
return hasRetryable
}
// executeServerToolsParallelWithTrace executes tools for a single server in parallel with trace
func (ast *Assistant) executeServerToolsParallelWithTrace(mcpCtx context.Context, ctx *agentContext.Context, trace types.Manager, client mcp.Client, serverID string, toolCalls []agentContext.ToolCall) ([]ToolCallResult, bool) {
// Prepare parallel trace inputs
var parallelInputs []types.TraceParallelInput
mcpCalls := make([]mcpTypes.ToolCall, 0, len(toolCalls))
orderedCalls := make([]agentContext.ToolCall, 0, len(toolCalls))
for _, tc := range toolCalls {
_, toolName, ok := ParseMCPToolName(tc.Function.Name)
if !ok {
continue
}
var args map[string]interface{}
if tc.Function.Arguments != "" {
if err := jsoniter.UnmarshalFromString(tc.Function.Arguments, &args); err != nil {
ctx.Logger.Error("Failed to parse arguments for %s: %v", toolName, err)
continue
}
}
mcpCalls = append(mcpCalls, mcpTypes.ToolCall{
Name: toolName,
Arguments: args,
})
orderedCalls = append(orderedCalls, tc)
ctx.Logger.ToolStart(tc.Function.Name)
// Add trace input for this tool
parallelInputs = append(parallelInputs, types.TraceParallelInput{
Input: map[string]any{
"tool_call_id": tc.ID,
"server": serverID,
"tool": toolName,
"arguments": tc.Function.Arguments,
},
Option: types.TraceNodeOption{
Label: fmt.Sprintf("Tool: %s", toolName),
Type: "mcp_tool",
Icon: "build",
Description: fmt.Sprintf("Calling MCP tool '%s' on server '%s'", toolName, serverID),
},
})
}
// Create parallel trace nodes
var toolNodes []types.Node
if trace != nil && len(parallelInputs) > 0 {
var err error
toolNodes, err = trace.Parallel(parallelInputs)
if err != nil {
ctx.Logger.Debug("trace.Parallel() failed: %v", err)
}
}
// Call tools in parallel with agent context as extra argument
ctx.Logger.Debug("Calling %d tools in parallel on server '%s'", len(mcpCalls), serverID)
// Pass agent context as extra argument (only used for Process transport)
mcpResponse, err := client.CallToolsParallel(mcpCtx, mcpCalls, ctx)
if err != nil {
ctx.Logger.Error("Parallel call failed: %v", err)
for i, node := range toolNodes {
if node != nil {
node.Fail(err)
}
if i < len(orderedCalls) {
ctx.Logger.ToolComplete(orderedCalls[i].Function.Name, false)
}
}
return nil, true
}
// Process results
results := make([]ToolCallResult, 0, len(mcpResponse.Results))
hasErrors := false
for i, mcpResult := range mcpResponse.Results {
toolName := mcpCalls[i].Name
originalCall := orderedCalls[i]
var toolNode types.Node
if i < len(toolNodes) {
toolNode = toolNodes[i]
}
result := ToolCallResult{
ToolCallID: originalCall.ID,
Name: originalCall.Function.Name,
}
// Serialize content
contentBytes, err := jsoniter.Marshal(mcpResult.Content)
if err != nil {
result.Error = fmt.Errorf("failed to serialize result: %w", err)
result.Content = result.Error.Error()
result.IsRetryableError = false // Serialization error is not retryable
hasErrors = true
ctx.Logger.ToolComplete(originalCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
} else {
result.Content = string(contentBytes)
// Check if it's an error result
if mcpResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
hasErrors = true
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(originalCall.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
} else {
ctx.Logger.ToolComplete(originalCall.Function.Name, true)
if toolNode != nil {
toolNode.Complete(map[string]any{
"result": mcpResult.Content,
})
}
}
}
results = append(results, result)
}
return results, hasErrors
}
// executeServerToolsSequentialWithTrace executes tools for a single server sequentially with trace
func (ast *Assistant) executeServerToolsSequentialWithTrace(mcpCtx context.Context, ctx *agentContext.Context, trace types.Manager, client mcp.Client, serverID string, toolCalls []agentContext.ToolCall) ([]ToolCallResult, bool) {
results := make([]ToolCallResult, 0, len(toolCalls))
hasErrors := false
ctx.Logger.Debug("Calling %d tools sequentially on server '%s'", len(toolCalls), serverID)
for _, tc := range toolCalls {
ctx.Logger.ToolStart(tc.Function.Name)
_, toolName, ok := ParseMCPToolName(tc.Function.Name)
if !ok {
results = append(results, ToolCallResult{
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: fmt.Sprintf("Invalid tool name format: %s", tc.Function.Name),
Error: fmt.Errorf("invalid tool name format"),
})
ctx.Logger.ToolComplete(tc.Function.Name, false)
hasErrors = true
continue
}
// Get tool schema for validation
toolsResponse, err := client.ListTools(mcpCtx, "")
var toolSchema interface{}
if err == nil {
for _, t := range toolsResponse.Tools {
if t.Name == toolName {
toolSchema = t.InputSchema
break
}
}
}
// Add trace node for this tool call
var toolNode types.Node
if trace != nil {
toolNode, _ = trace.Add(
map[string]any{
"tool_call_id": tc.ID,
"server": serverID,
"tool": toolName,
"arguments": tc.Function.Arguments,
},
types.TraceNodeOption{
Label: fmt.Sprintf("Tool: %s (sequential retry)", toolName),
Type: "mcp_tool",
Icon: "build",
Description: fmt.Sprintf("Retrying MCP tool '%s' on server '%s' sequentially", toolName, serverID),
},
)
}
// Parse arguments with repair support
var args map[string]interface{}
if tc.Function.Arguments != "" {
parsed, err := gouJson.Parse(tc.Function.Arguments)
if err != nil {
result := ToolCallResult{
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: fmt.Sprintf("Failed to parse arguments: %v", err),
Error: err,
IsRetryableError: true, // Parsing error is retryable
}
results = append(results, result)
hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(err)
}
continue
}
// Convert to map
if argsMap, ok := parsed.(map[string]interface{}); ok {
args = argsMap
} else {
err := fmt.Errorf("arguments must be an object, got %T", parsed)
result := ToolCallResult{
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: err.Error(),
Error: err,
IsRetryableError: true, // Type error is retryable
}
results = append(results, result)
hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(err)
}
continue
}
// Validate arguments against tool schema if available
if toolSchema != nil {
if err := gouJson.Validate(args, toolSchema); err != nil {
result := ToolCallResult{
ToolCallID: tc.ID,
Name: tc.Function.Name,
Content: fmt.Sprintf("Argument validation failed: %v", err),
Error: err,
IsRetryableError: true, // Validation error is retryable
}
results = append(results, result)
hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(err)
}
continue
}
}
}
// Call single tool with agent context as extra argument
ctx.Logger.Debug("Calling tool: %s", toolName)
mcpResult, err := client.CallTool(mcpCtx, toolName, args, ctx)
result := ToolCallResult{
ToolCallID: tc.ID,
Name: tc.Function.Name,
}
if err != nil {
result.Error = err
result.Content = fmt.Sprintf("Tool call failed: %v", err)
result.IsRetryableError = isRetryableToolError(err)
hasErrors = true
ctx.Logger.Error("Tool call failed: %s - %v (retryable: %v)", toolName, err, result.IsRetryableError)
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(err)
}
} else {
// Serialize the Content field only ([]ToolContent)
contentBytes, err := jsoniter.Marshal(mcpResult.Content)
if err != nil {
result.Error = err
result.Content = fmt.Sprintf("Failed to serialize result: %v", err)
result.IsRetryableError = false
hasErrors = true
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(err)
}
} else {
result.Content = string(contentBytes)
// Check if result is an error — include actual content so LLM can see the details
if mcpResult.IsError {
result.Error = fmt.Errorf("tool call error: %s", result.Content)
result.IsRetryableError = isRetryableToolError(result.Error)
hasErrors = true
ctx.Logger.Error("Tool call failed: %s - %s (retryable: %v)", toolName, result.Content, result.IsRetryableError)
ctx.Logger.ToolComplete(tc.Function.Name, false)
if toolNode != nil {
toolNode.Fail(result.Error)
}
} else {
ctx.Logger.ToolComplete(tc.Function.Name, true)
if toolNode != nil {
toolNode.Complete(map[string]any{
"result": mcpResult.Content,
})
}
}
}
}
results = append(results, result)
}
return results, hasErrors
}

View file

@ -1,401 +0,0 @@
package assistant_test
import (
"context"
"testing"
jsoniter "github.com/json-iterator/go"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/mcp"
mcpTypes "github.com/yaoapp/gou/mcp/types"
"github.com/yaoapp/yao/agent/assistant"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
func TestMCPToolName(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
tests := []struct {
name string
serverID string
toolName string
wantResult string
}{
{
name: "Simple tool name",
serverID: "github",
toolName: "search_repos",
wantResult: "github__search_repos",
},
{
name: "Server with dots",
serverID: "github.enterprise",
toolName: "search_repos",
wantResult: "github_enterprise__search_repos",
},
{
name: "Tool with underscores",
serverID: "customer-db",
toolName: "create_customer",
wantResult: "customer-db__create_customer",
},
{
name: "Complex server with multiple dots",
serverID: "com.example.mcp",
toolName: "tool_name",
wantResult: "com_example_mcp__tool_name",
},
{
name: "Empty server ID",
serverID: "",
toolName: "tool",
wantResult: "",
},
{
name: "Empty tool name",
serverID: "server",
toolName: "",
wantResult: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := assistant.MCPToolName(tt.serverID, tt.toolName)
if result != tt.wantResult {
t.Errorf("MCPToolName() = %v, want %v", result, tt.wantResult)
}
})
}
}
func TestParseMCPToolName(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
tests := []struct {
name string
formattedName string
wantServerID string
wantToolName string
wantOK bool
}{
{
name: "Valid simple format",
formattedName: "github__search_repos",
wantServerID: "github",
wantToolName: "search_repos",
wantOK: true,
},
{
name: "Server with dots restored",
formattedName: "github_enterprise__search_repos",
wantServerID: "github.enterprise",
wantToolName: "search_repos",
wantOK: true,
},
{
name: "Complex server ID with multiple dots",
formattedName: "com_example_mcp_server__tool_name",
wantServerID: "com.example.mcp.server",
wantToolName: "tool_name",
wantOK: true,
},
{
name: "Tool name with underscores",
formattedName: "server__create_new_user",
wantServerID: "server",
wantToolName: "create_new_user",
wantOK: true,
},
{
name: "Server with hyphens",
formattedName: "mcp-server__tool",
wantServerID: "mcp-server",
wantToolName: "tool",
wantOK: true,
},
{
name: "Invalid format - no double underscore",
formattedName: "invalid",
wantServerID: "",
wantToolName: "",
wantOK: false,
},
{
name: "Invalid format - empty string",
formattedName: "",
wantServerID: "",
wantToolName: "",
wantOK: false,
},
{
name: "Invalid format - only double underscore",
formattedName: "__",
wantServerID: "",
wantToolName: "",
wantOK: false,
},
{
name: "Invalid format - ends with double underscore",
formattedName: "server__",
wantServerID: "",
wantToolName: "",
wantOK: false,
},
{
name: "Invalid format - starts with double underscore",
formattedName: "__tool",
wantServerID: "",
wantToolName: "",
wantOK: false,
},
{
name: "Invalid format - multiple double underscores",
formattedName: "server__middle__tool",
wantServerID: "",
wantToolName: "",
wantOK: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
serverID, toolName, ok := assistant.ParseMCPToolName(tt.formattedName)
if serverID != tt.wantServerID {
t.Errorf("ParseMCPToolName() serverID = %v, want %v", serverID, tt.wantServerID)
}
if toolName != tt.wantToolName {
t.Errorf("ParseMCPToolName() toolName = %v, want %v", toolName, tt.wantToolName)
}
if ok != tt.wantOK {
t.Errorf("ParseMCPToolName() ok = %v, want %v", ok, tt.wantOK)
}
})
}
}
func TestMCPToolName_RoundTrip(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
tests := []struct {
name string
serverID string
toolName string
}{
{
name: "Simple IDs",
serverID: "github",
toolName: "search_repos",
},
{
name: "Server with dots",
serverID: "github.enterprise",
toolName: "search",
},
{
name: "Complex server ID",
serverID: "com.example.mcp.server",
toolName: "tool_name",
},
{
name: "Server with dashes",
serverID: "mcp-server-123",
toolName: "tool_with_underscores",
},
{
name: "Mixed dots and dashes",
serverID: "github.enterprise-prod",
toolName: "api_call",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Format
formatted := assistant.MCPToolName(tt.serverID, tt.toolName)
if formatted == "" {
t.Fatal("MCPToolName() returned empty string")
}
// Parse
serverID, toolName, ok := assistant.ParseMCPToolName(formatted)
// Verify round-trip
if !ok {
t.Fatal("ParseMCPToolName() failed")
}
if serverID != tt.serverID {
t.Errorf("Round-trip failed: serverID = %v, want %v", serverID, tt.serverID)
}
if toolName != tt.toolName {
t.Errorf("Round-trip failed: toolName = %v, want %v", toolName, tt.toolName)
}
t.Logf("✓ Round-trip successful: (%s, %s) → %s → (%s, %s)",
tt.serverID, tt.toolName, formatted, serverID, toolName)
})
}
}
// TestMCPToolContextPassing tests that agent context is correctly passed to MCP tools
func TestMCPToolContextPassing(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Get the echo MCP client
client, err := mcp.Select("echo")
assert.NoError(t, err, "Failed to select echo MCP client")
assert.NotNil(t, client, "MCP client should not be nil")
// Create a test agent context
authorized := &types.AuthorizedInfo{
UserID: "test-user-123",
TenantID: "test-tenant-456",
}
ctx := agentContext.New(context.Background(), authorized, "test-chat-789")
ctx.AssistantID = "test-assistant-mcptest"
ctx.Locale = "en"
ctx.Theme = "dark"
// Call the echo tool with context
args := map[string]interface{}{
"message": "test message from context test",
}
// Call the tool - the agent context will be passed as extra parameter
result, err := client.CallTool(ctx.Context, "echo", args, ctx)
assert.NoError(t, err, "CallTool should not return error")
assert.NotNil(t, result, "Result should not be nil")
assert.False(t, result.IsError, "Result should not be an error")
assert.Greater(t, len(result.Content), 0, "Result should have content")
// Parse the result content
var echoResult map[string]interface{}
err = jsoniter.Unmarshal([]byte(result.Content[0].Text), &echoResult)
assert.NoError(t, err, "Failed to parse result content")
t.Logf("Echo result: %+v", echoResult)
// Verify the context was received
contextData, ok := echoResult["context"].(map[string]interface{})
assert.True(t, ok, "Result should contain context field")
assert.NotNil(t, contextData, "Context data should not be nil")
// Verify context has_context flag
hasContext, ok := contextData["has_context"].(bool)
assert.True(t, ok, "Context should have has_context field")
assert.True(t, hasContext, "Context should indicate it has context")
// Verify chat_id and assistant_id have values (main verification)
chatID, ok := contextData["chat_id"].(string)
assert.True(t, ok, "Context should have chat_id field")
assert.NotEmpty(t, chatID, "chat_id should have a value")
assert.Equal(t, "test-chat-789", chatID, "chat_id should match")
assistantID, ok := contextData["assistant_id"].(string)
assert.True(t, ok, "Context should have assistant_id field")
assert.NotEmpty(t, assistantID, "assistant_id should have a value")
assert.Equal(t, "test-assistant-mcptest", assistantID, "assistant_id should match")
// Verify authorized information
authorizedData, ok := contextData["authorized"].(map[string]interface{})
assert.True(t, ok, "Context should have authorized field")
assert.NotNil(t, authorizedData, "Authorized data should not be nil")
userID, ok := authorizedData["user_id"].(string)
assert.True(t, ok, "Authorized should have user_id field")
assert.Equal(t, "test-user-123", userID, "User ID should match")
tenantID, ok := authorizedData["tenant_id"].(string)
assert.True(t, ok, "Authorized should have tenant_id field")
assert.Equal(t, "test-tenant-456", tenantID, "Tenant ID should match")
t.Logf("✓ Context successfully passed to MCP tool")
t.Logf(" - ChatID: %s", chatID)
t.Logf(" - AssistantID: %s", assistantID)
t.Logf(" - UserID: %s", userID)
t.Logf(" - TenantID: %s", tenantID)
}
// TestMCPToolContextPassingParallel tests that agent context is correctly passed in parallel calls
func TestMCPToolContextPassingParallel(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Get the echo MCP client
client, err := mcp.Select("echo")
assert.NoError(t, err, "Failed to select echo MCP client")
assert.NotNil(t, client, "MCP client should not be nil")
// Create a test agent context
authorized := &types.AuthorizedInfo{
UserID: "parallel-user-123",
TenantID: "parallel-tenant-456",
}
ctx := agentContext.New(context.Background(), authorized, "parallel-chat-789")
ctx.AssistantID = "test-assistant-parallel"
ctx.Locale = "zh-CN"
// Call multiple echo tools in parallel
toolCalls := []mcpTypes.ToolCall{
{
Name: "echo",
Arguments: map[string]interface{}{
"message": "parallel message 1",
},
},
{
Name: "echo",
Arguments: map[string]interface{}{
"message": "parallel message 2",
},
},
}
// Call tools in parallel - the agent context will be passed as extra parameter
results, err := client.CallToolsParallel(ctx.Context, toolCalls, ctx)
assert.NoError(t, err, "CallToolsParallel should not return error")
assert.NotNil(t, results, "Results should not be nil")
assert.Equal(t, 2, len(results.Results), "Should have 2 results")
// Verify both results received the context
for i, result := range results.Results {
assert.False(t, result.IsError, "Result %d should not be an error", i)
assert.Greater(t, len(result.Content), 0, "Result %d should have content", i)
// Parse the result content
var echoResult map[string]interface{}
err = jsoniter.Unmarshal([]byte(result.Content[0].Text), &echoResult)
assert.NoError(t, err, "Failed to parse result %d content", i)
// Verify the context was received
contextData, ok := echoResult["context"].(map[string]interface{})
assert.True(t, ok, "Result %d should contain context field", i)
assert.NotNil(t, contextData, "Context data %d should not be nil", i)
hasContext, ok := contextData["has_context"].(bool)
assert.True(t, ok, "Context %d should have has_context field", i)
assert.True(t, hasContext, "Context %d should indicate it has context", i)
// Verify chat_id in parallel call
chatID, ok := contextData["chat_id"].(string)
assert.True(t, ok, "Context %d should have chat_id field", i)
assert.Equal(t, "parallel-chat-789", chatID, "Chat ID in result %d should match", i)
// Verify authorized information in parallel call
authorizedData, ok := contextData["authorized"].(map[string]interface{})
assert.True(t, ok, "Context %d should have authorized field", i)
if userID, ok := authorizedData["user_id"].(string); ok {
assert.Equal(t, "parallel-user-123", userID, "User ID in result %d should match", i)
}
t.Logf("✓ Result %d successfully received context", i)
}
t.Log("✓ Context successfully passed to all parallel MCP tool calls")
}

View file

@ -1,88 +0,0 @@
package assistant
import (
"fmt"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
)
// processNextResponse processes the Next hook's response and handles agent delegation or custom data
func (ast *Assistant) processNextResponse(npc *NextProcessContext) (*agentContext.Response, error) {
// If no Next hook response, return standard response
if npc.NextResponse == nil {
return ast.buildStandardResponse(npc), nil
}
// Handle Delegate: call another agent
// Note: User input is already buffered by root agent, delegated agent will skip buffering
if npc.NextResponse.Delegate != nil {
return ast.handleDelegation(npc.Context, npc.NextResponse.Delegate, npc.StreamHandler)
}
// Handle custom Data: return as-is wrapped in standard Response
if npc.NextResponse.Data != nil {
return &agentContext.Response{
ContextID: npc.Context.ID,
RequestID: npc.Context.RequestID(),
TraceID: npc.Context.TraceID(),
ChatID: npc.Context.ChatID,
AssistantID: ast.ID,
Create: npc.CreateResponse,
Next: npc.NextResponse.Data, // Put custom data in Next field
Completion: npc.CompletionResponse,
Tools: npc.ToolCallResponses,
}, nil
}
// No delegate or data, return standard response
return ast.buildStandardResponse(npc), nil
}
// handleDelegation handles calling another agent based on DelegateConfig
func (ast *Assistant) handleDelegation(
ctx *agentContext.Context,
delegate *agentContext.DelegateConfig,
streamHandler func(message.StreamChunkType, []byte) int,
) (*agentContext.Response, error) {
// Load the target assistant
targetAssistant, err := Get(delegate.AgentID)
if err != nil {
return nil, fmt.Errorf("failed to load delegated assistant '%s': %w", delegate.AgentID, err)
}
// Mark this as an agent-to-agent call for proper source tracking
ctx.Referer = agentContext.RefererAgent
// Call the delegated assistant with the same context
// The delegated assistant's Stream method will:
// 1. Call EnterStack() to push itself onto the Stack (creating parent-child relationship)
// 2. Execute with the same Context (preserving ID, Space, Writer, etc.)
// 3. Call done() to pop from Stack when finished
// This ensures proper Stack tracing: parent assistant -> delegated assistant
// Convert options map from delegate config to Options struct
delegateOpts := agentContext.OptionsFromMap(delegate.Options)
return targetAssistant.Stream(ctx, delegate.Messages, delegateOpts)
}
// buildStandardResponse builds the standard agent response when no custom Next hook processing is needed
func (ast *Assistant) buildStandardResponse(npc *NextProcessContext) *agentContext.Response {
var next interface{} = nil
if npc.NextResponse != nil {
next = npc.NextResponse
}
return &agentContext.Response{
ContextID: npc.Context.ID,
RequestID: npc.Context.RequestID(),
TraceID: npc.Context.TraceID(),
ChatID: npc.Context.ChatID,
AssistantID: ast.ID,
Create: npc.CreateResponse,
Next: next,
Completion: npc.CompletionResponse,
Tools: npc.ToolCallResponses,
}
}

View file

@ -1,14 +0,0 @@
package assistant
import (
"fmt"
"github.com/yaoapp/yao/agent/context"
)
func (ast *Assistant) checkPermissions(ctx *context.Context) error {
if ctx.Authorized == nil {
return fmt.Errorf("authorized information not found")
}
return nil
}

View file

@ -1,467 +0,0 @@
package assistant
import (
stdContext "context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/yaoapp/gou/connector"
goullm "github.com/yaoapp/gou/llm"
gouMCP "github.com/yaoapp/gou/mcp"
mcpProcess "github.com/yaoapp/gou/mcp/process"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message"
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/config"
infraSandbox "github.com/yaoapp/yao/sandbox"
"github.com/yaoapp/yao/sandbox/ipc"
traceTypes "github.com/yaoapp/yao/trace/types"
)
var (
sandboxManager *infraSandbox.Manager
sandboxManagerOnce sync.Once
sandboxManagerErr error
)
// GetSandboxManager returns the sandbox manager singleton
// Returns nil and error if sandbox is not configured or Docker is unavailable
func GetSandboxManager() (*infraSandbox.Manager, error) {
sandboxManagerOnce.Do(func() {
// Create sandbox config from Yao config
cfg := &infraSandbox.Config{}
// Use YAO_DATA_ROOT for workspace and IPC paths
dataRoot := config.Conf.DataRoot
if dataRoot != "" {
cfg.Init(dataRoot)
}
// Create manager (will fail if Docker is not available)
sandboxManager, sandboxManagerErr = infraSandbox.NewManager(cfg)
})
return sandboxManager, sandboxManagerErr
}
// HasSandbox returns true if the assistant has sandbox configuration
func (ast *Assistant) HasSandbox() bool {
return ast.Sandbox != nil && ast.Sandbox.Command != ""
}
// initSandbox initializes the sandbox executor
// Returns the full Executor (for LLM calls), cleanup function, and any error
// This is called BEFORE hooks so that hooks can access ctx.sandbox
// The executor implements both agentsandbox.Executor and context.SandboxExecutor interfaces
func (ast *Assistant) initSandbox(ctx *context.Context, opts *context.Options) (agentsandbox.Executor, func(), string, error) {
// Get sandbox manager (singleton)
manager, err := GetSandboxManager()
if err != nil {
ctx.Logger.Error("Sandbox manager initialization failed: %v", err)
return nil, nil, "", fmt.Errorf("sandbox manager not available: %w", err)
}
if manager == nil {
return nil, nil, "", fmt.Errorf("sandbox manager not initialized")
}
// Build executor options from assistant config
execOpts, err := ast.buildSandboxOptions(ctx, opts)
if err != nil {
ctx.Logger.Error("Failed to build sandbox options: %v", err)
return nil, nil, "", fmt.Errorf("failed to build sandbox options: %w", err)
}
// Log sandbox creation
ctx.Logger.Info("Creating sandbox container for command: %s", ast.Sandbox.Command)
// Add trace for sandbox creation
trace, traceErr := ctx.Trace()
if traceErr == nil && trace != nil {
trace.Info("Creating sandbox container...")
}
// Send loading message to user
loadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": i18n.T(ctx.Locale, "sandbox.preparing"),
},
}
loadingMsgID, _ := ctx.SendStream(loadingMsg)
// Create executor (container starts here)
executor, err := agentsandbox.New(manager, execOpts)
if err != nil {
ctx.Logger.Error("Sandbox creation failed: %v", err)
if traceErr == nil && trace != nil {
trace.Error("Sandbox creation failed: %v", err)
}
// End loading message with done:true
if loadingMsgID != "" {
doneMsg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": i18n.T(ctx.Locale, "sandbox.failed"),
"done": true,
},
}
ctx.Send(doneMsg)
}
return nil, nil, "", fmt.Errorf("failed to create sandbox executor: %w", err)
}
// Log sandbox ready
ctx.Logger.Info("Sandbox container ready")
if traceErr == nil && trace != nil {
trace.Info("Sandbox container ready")
}
// Return cleanup function
cleanup := func() {
if err := executor.Close(); err != nil {
ctx.Logger.Error("Failed to close sandbox executor: %v", err)
}
}
// Keep loadingMsgID open - it will be closed when first output is received
// This provides better UX: user sees "Preparing..." until actual content appears
return executor, cleanup, loadingMsgID, nil
}
// executeSandboxStream executes the request using sandbox (Claude CLI, etc.)
// This is called when ast.Sandbox is configured
// NOTE: The executor is passed directly from initSandbox, no type assertion needed
func (ast *Assistant) executeSandboxStream(
ctx *context.Context,
completionMessages []context.Message,
agentNode traceTypes.Node,
streamHandler message.StreamFunc,
executor agentsandbox.Executor,
loadingMsgID string,
) (*context.CompletionResponse, error) {
// Mark the agentNode as used to avoid unused variable error
_ = agentNode
if executor == nil {
return nil, fmt.Errorf("sandbox executor not initialized (call initSandbox first)")
}
// Log sandbox execution
ctx.Logger.Info("Executing via sandbox (command: %s)", ast.Sandbox.Command)
// Pass the "preparing sandbox" loading message ID to executor
// It will be closed when first output (text or tool) is received
if loadingMsgID != "" {
executor.SetLoadingMsgID(loadingMsgID)
}
// Execute LLM call via sandbox
// The loadingMsgID will be closed when first output is received
// Tool calls will create their own loading messages below the text
resp, err := executor.Stream(ctx, completionMessages, streamHandler)
if err != nil {
// Close loading message on error
if loadingMsgID != "" {
doneMsg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]interface{}{
"message": i18n.T(ctx.Locale, "sandbox.failed"),
"done": true,
},
}
ctx.Send(doneMsg)
}
// Send error message to client
errMsg := &message.Message{
Type: message.TypeError,
Props: map[string]interface{}{
"message": err.Error(),
},
}
ctx.Send(errMsg)
return nil, fmt.Errorf("sandbox execution failed: %w", err)
}
return resp, nil
}
// buildSandboxOptions builds executor options from assistant config
func (ast *Assistant) buildSandboxOptions(ctx *context.Context, opts *context.Options) (*agentsandbox.Options, error) {
if ast.Sandbox == nil {
return nil, fmt.Errorf("sandbox configuration is required")
}
execOpts := &agentsandbox.Options{
Command: ast.Sandbox.Command,
Image: ast.Sandbox.Image,
MaxMemory: ast.Sandbox.MaxMemory,
MaxCPU: ast.Sandbox.MaxCPU,
Arguments: ast.Sandbox.Arguments,
}
// Parse timeout string (e.g., "10m") to duration
if ast.Sandbox.Timeout != "" {
timeout, err := time.ParseDuration(ast.Sandbox.Timeout)
if err != nil {
return nil, fmt.Errorf("invalid timeout format: %w", err)
}
execOpts.Timeout = timeout
}
// Set user and chat IDs for workspace isolation
if ctx.Authorized != nil && ctx.Authorized.UserID != "" {
execOpts.UserID = ctx.Authorized.UserID
} else {
execOpts.UserID = "anonymous"
}
execOpts.ChatID = ctx.ChatID
// Set skills directory (auto-resolved from assistant path)
// Only set if the directory actually exists
if ast.Path != "" {
appRoot := config.Conf.AppSource
skillsDir := filepath.Join(appRoot, ast.Path, "skills")
if info, err := os.Stat(skillsDir); err == nil && info.IsDir() {
execOpts.SkillsDir = skillsDir
ctx.Logger.Debug("Skills directory found: %s", skillsDir)
}
}
// Check if assistant has prompts (from prompts.yml)
// If prompts are configured, we need to call Claude CLI
if len(ast.Prompts) > 0 {
// Extract system prompt from prompts
for _, prompt := range ast.Prompts {
if prompt.Role == "system" && prompt.Content != "" {
execOpts.SystemPrompt = prompt.Content
break
}
}
}
// Resolve connector settings
conn, _, err := ast.GetConnector(ctx, opts)
if err != nil {
return nil, fmt.Errorf("failed to get connector: %w", err)
}
// Determine connector type for sandbox proxy behavior
// Anthropic connectors bypass the proxy (Claude CLI connects directly)
if conn.Is(connector.ANTHROPIC) {
execOpts.ConnectorType = "anthropic"
} else {
execOpts.ConnectorType = "openai"
}
// Extract standard fields via LLMConnector when available, fallback to Setting()
setting := conn.Setting()
if lc, ok := conn.(goullm.LLMConnector); ok {
execOpts.ConnectorHost = lc.GetURL()
execOpts.ConnectorKey = lc.GetKey()
execOpts.Model = lc.GetModel()
} else {
if host, ok := setting["host"].(string); ok {
execOpts.ConnectorHost = host
}
if key, ok := setting["key"].(string); ok {
execOpts.ConnectorKey = key
}
if model, ok := setting["model"].(string); ok {
execOpts.Model = model
}
}
// Whitelist-filter remaining settings for sandbox proxy options
connectorOptions := connector.FilterRequestBodyParams(setting, conn)
if len(connectorOptions) > 0 {
execOpts.ConnectorOptions = connectorOptions
ctx.Logger.Debug("Connector options extracted: %v", connectorOptions)
}
// Extract secrets from sandbox config (e.g., GITHUB_TOKEN: "$ENV.GITHUB_TOKEN")
if ast.Sandbox != nil && len(ast.Sandbox.Secrets) > 0 {
secrets := make(map[string]string)
for k, v := range ast.Sandbox.Secrets {
// Resolve $ENV.XXX references
resolved := resolveEnvValue(v)
if resolved != "" {
secrets[k] = resolved
}
}
if len(secrets) > 0 {
execOpts.Secrets = secrets
ctx.Logger.Debug("Secrets extracted: %d items", len(secrets))
}
}
// Build MCP config and load tools if the assistant has MCP servers configured
if ast.MCP != nil && len(ast.MCP.Servers) > 0 {
// Build MCP config for Claude CLI
mcpConfig, err := ast.BuildMCPConfigForSandbox(ctx)
if err != nil {
ctx.Logger.Warn("Failed to build MCP config for sandbox: %v", err)
// Non-fatal: sandbox can work without MCP
} else {
execOpts.MCPConfig = mcpConfig
ctx.Logger.Debug("MCP config built for sandbox (%d bytes)", len(mcpConfig))
}
// Load MCP tools for IPC session
mcpTools, err := ast.loadMCPToolsForIPC(ctx)
if err != nil {
ctx.Logger.Warn("Failed to load MCP tools for IPC: %v", err)
// Non-fatal: IPC will have no tools
} else if len(mcpTools) > 0 {
execOpts.MCPTools = mcpTools
ctx.Logger.Debug("Loaded %d MCP tools for IPC", len(mcpTools))
}
}
return execOpts, nil
}
// loadMCPToolsForIPC loads MCP tools from configured servers and converts them to IPC format
func (ast *Assistant) loadMCPToolsForIPC(ctx *context.Context) (map[string]*ipc.MCPTool, error) {
if ast.MCP == nil || len(ast.MCP.Servers) == 0 {
return nil, nil
}
tools := make(map[string]*ipc.MCPTool)
stdCtx := ctx.Context
if stdCtx == nil {
stdCtx = stdContext.Background()
}
for _, serverConfig := range ast.MCP.Servers {
if serverConfig.ServerID == "" {
continue
}
// Get MCP client
client, err := gouMCP.Select(serverConfig.ServerID)
if err != nil {
ctx.Logger.Warn("MCP server '%s' not found: %v", serverConfig.ServerID, err)
continue
}
// List tools from the MCP client
toolsResp, err := client.ListTools(stdCtx, "")
if err != nil {
ctx.Logger.Warn("Failed to list tools from MCP server '%s': %v", serverConfig.ServerID, err)
continue
}
// Get tool mapping for process names
mapping, ok := mcpProcess.GetMapping(serverConfig.ServerID)
if !ok {
ctx.Logger.Warn("No mapping found for MCP server '%s'", serverConfig.ServerID)
continue
}
// Filter tools if specified in config
toolFilter := make(map[string]bool)
if len(serverConfig.Tools) > 0 {
for _, t := range serverConfig.Tools {
toolFilter[t] = true
}
}
// Convert tools to IPC format
// Tool names are prefixed with server ID to avoid conflicts
// e.g., "echo" server's "ping" tool becomes "echo__ping"
for _, tool := range toolsResp.Tools {
// Apply tool filter if specified
if len(toolFilter) > 0 && !toolFilter[tool.Name] {
continue
}
// Find the process name from mapping
processName := ""
if toolSchema, ok := mapping.Tools[tool.Name]; ok {
processName = toolSchema.Process
}
if processName == "" {
ctx.Logger.Warn("No process mapping for tool '%s' in server '%s'", tool.Name, serverConfig.ServerID)
continue
}
// Prefixed tool name: serverID__toolName
// This matches Claude's MCP naming: mcp__yao__serverID__toolName
prefixedName := serverConfig.ServerID + "__" + tool.Name
// Create IPC tool entry with prefixed name
ipcTool := &ipc.MCPTool{
Name: prefixedName,
Description: tool.Description,
Process: processName,
InputSchema: tool.InputSchema,
}
tools[prefixedName] = ipcTool
}
}
return tools, nil
}
// BuildMCPConfigForSandbox builds the MCP configuration JSON for sandbox
// This creates a .mcp.json format that Claude CLI can understand
// Exported for testing
func (ast *Assistant) BuildMCPConfigForSandbox(ctx *context.Context) ([]byte, error) {
if ast.MCP == nil || len(ast.MCP.Servers) == 0 {
return nil, nil
}
// Build MCP config in Claude CLI format
// Claude CLI expects: { "mcpServers": { "server_id": { "command": "...", "args": [...] } } }
//
// For Yao's MCP servers, we use yao-bridge to connect to the IPC socket.
// yao-bridge bridges stdio to Unix socket, allowing Claude CLI to communicate
// with Yao's IPC server running on the host.
//
// Architecture:
// Claude CLI → yao-bridge → Unix Socket → IPC Session → Yao Process
config := map[string]interface{}{
"mcpServers": map[string]interface{}{
// Single "yao" server that handles all MCP tools via IPC
"yao": map[string]interface{}{
"command": "yao-bridge",
"args": []string{"/tmp/yao.sock"}, // ContainerIPCSocket from sandbox config
},
},
}
return json.Marshal(config)
}
// resolveEnvValue resolves environment variable references in a string
// Supports format: $ENV.VAR_NAME or plain value
// Returns empty string if the variable is not set
func resolveEnvValue(value string) string {
if value == "" {
return ""
}
// Check for $ENV.XXX format
if len(value) > 5 && value[:5] == "$ENV." {
envName := value[5:]
return os.Getenv(envName)
}
// Return as-is if not an env reference
return value
}

View file

@ -1,82 +0,0 @@
package assistant_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/testutils"
)
// TestSandboxDebugHasSandbox tests the HasSandbox method directly
func TestSandboxDebugHasSandbox(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
testCases := []struct {
name string
assistantID string
expectTrue bool
}{
{"BasicSandbox", "tests.sandbox.basic", true},
{"HooksSandbox", "tests.sandbox.hooks", true},
{"FullSandbox", "tests.sandbox.full", true},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ast, err := assistant.Get(tc.assistantID)
require.NoError(t, err, "Failed to get assistant %s", tc.assistantID)
// Check Sandbox struct
t.Logf("Assistant ID: %s", ast.ID)
t.Logf("Sandbox: %+v", ast.Sandbox)
if ast.Sandbox != nil {
t.Logf("Sandbox.Command: %q", ast.Sandbox.Command)
t.Logf("Sandbox.Timeout: %s", ast.Sandbox.Timeout)
t.Logf("Sandbox.Image: %s", ast.Sandbox.Image)
t.Logf("Sandbox.Arguments: %v", ast.Sandbox.Arguments)
}
// Check HasSandbox
hasSandbox := ast.HasSandbox()
t.Logf("HasSandbox() = %v", hasSandbox)
if tc.expectTrue {
assert.True(t, hasSandbox, "Expected HasSandbox() to be true for %s", tc.assistantID)
} else {
assert.False(t, hasSandbox, "Expected HasSandbox() to be false for %s", tc.assistantID)
}
})
}
}
// TestSandboxDebugPrompts tests if Prompts is set (affects execution path)
func TestSandboxDebugPrompts(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("tests.sandbox.basic")
require.NoError(t, err)
t.Logf("Assistant ID: %s", ast.ID)
t.Logf("Prompts: %v", ast.Prompts)
t.Logf("MCP: %v", ast.MCP)
t.Logf("HasSandbox: %v", ast.HasSandbox())
// The condition in agent.go is:
// if ast.Prompts != nil || ast.MCP != nil {
// // ... execute LLM
// if ast.HasSandbox() {
// // sandbox path
// } else {
// // direct LLM path
// }
// }
// So we need Prompts or MCP to be non-nil
if ast.Prompts == nil && ast.MCP == nil {
t.Log("WARNING: Neither Prompts nor MCP is set, LLM phase will be skipped entirely!")
}
}

View file

@ -1,481 +0,0 @@
package assistant_test
import (
stdContext "context"
"fmt"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newSandboxE2EContext creates a Context for sandbox E2E testing
// Uses unique chatID to avoid container name conflicts
func newSandboxE2EContext(chatIDPrefix, assistantID string) *context.Context {
// Generate unique chatID using timestamp to avoid container conflicts
chatID := fmt.Sprintf("%s-%d", chatIDPrefix, time.Now().UnixNano())
authorized := &types.AuthorizedInfo{
Subject: "sandbox-e2e-test-user",
ClientID: "sandbox-e2e-test-client",
Scope: "openid profile",
SessionID: "sandbox-e2e-test-session",
UserID: "sandbox-user-123",
TeamID: "sandbox-team-456",
TenantID: "sandbox-tenant-789",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Theme = "light"
ctx.Client = context.Client{
Type: "web",
UserAgent: "SandboxE2ETest/1.0",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.Route = ""
ctx.Metadata = make(map[string]interface{})
return ctx
}
// TestSandboxBasicE2E tests the basic sandbox assistant end-to-end
// This test verifies that:
// 1. Sandbox is correctly initialized
// 2. Claude CLI command is built correctly
// 3. Docker container is created and managed
func TestSandboxBasicE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the basic sandbox assistant
ast, err := assistant.Get("tests.sandbox.basic")
if err != nil {
t.Skipf("Skipping test: sandbox assistant not available: %v", err)
}
// Verify sandbox is configured
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
t.Logf("✓ Sandbox configured with command: %s", ast.Sandbox.Command)
// Create context
ctx := newSandboxE2EContext("sandbox-basic-e2e", "tests.sandbox.basic")
// Test messages
messages := []context.Message{
{Role: context.RoleUser, Content: "echo hello sandbox"},
}
// Execute stream
// Note: This will fail if Docker/Claude image is not available, which is expected in CI
response, err := ast.Stream(ctx, messages)
if err != nil {
// Check if it's a Docker/sandbox availability issue
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
// Verify response
require.NotNil(t, response, "Response should not be nil")
// Verify response completion (Claude CLI should return some response)
if response.Completion != nil && response.Completion.Content != nil {
if contentStr, ok := response.Completion.Content.(string); ok && contentStr != "" {
t.Logf("✓ Response content: %s", truncateString(contentStr, 200))
} else {
t.Logf("⚠ Response content type: %T", response.Completion.Content)
}
} else {
t.Log("⚠ Response content is empty (might be expected for some commands)")
}
t.Log("✓ Basic sandbox E2E test passed")
}
// truncateString truncates a string to maxLen and adds "..." if truncated
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// TestSandboxHooksE2E tests the sandbox assistant with hooks
func TestSandboxHooksE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the hooks sandbox assistant
ast, err := assistant.Get("tests.sandbox.hooks")
if err != nil {
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
}
// Verify sandbox and hooks are configured
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
t.Logf("✓ Sandbox and hooks configured")
// Create context
ctx := newSandboxE2EContext("sandbox-hooks-e2e", "tests.sandbox.hooks")
// Test messages
messages := []context.Message{
{Role: context.RoleUser, Content: "test hooks integration"},
}
// Execute stream
response, err := ast.Stream(ctx, messages)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
require.NotNil(t, response, "Response should not be nil")
t.Log("✓ Sandbox hooks E2E test passed")
}
// TestSandboxFullE2E tests the full sandbox assistant with MCPs and Skills
func TestSandboxFullE2E(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox E2E test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the full sandbox assistant
ast, err := assistant.Get("tests.sandbox.full")
if err != nil {
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
}
// Verify all components are configured
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
require.NotNil(t, ast.MCP, "MCP should be configured")
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
t.Logf("✓ Full sandbox configured: command=%s, MCP servers=%d",
ast.Sandbox.Command, len(ast.MCP.Servers))
// Verify MCP configuration
assert.Len(t, ast.MCP.Servers, 1)
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
t.Logf("✓ MCP server: %s with tools %v", ast.MCP.Servers[0].ServerID, ast.MCP.Servers[0].Tools)
// Create context
ctx := newSandboxE2EContext("sandbox-full-e2e", "tests.sandbox.full")
// Test messages
messages := []context.Message{
{Role: context.RoleUser, Content: "test full sandbox with MCP and skills"},
}
// Execute stream
response, err := ast.Stream(ctx, messages)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
require.NotNil(t, response, "Response should not be nil")
t.Log("✓ Full sandbox E2E test passed")
}
// TestSandboxContextAccess tests that sandbox is accessible in hooks via ctx.sandbox
func TestSandboxContextAccess(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox context access test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the hooks sandbox assistant
ast, err := assistant.Get("tests.sandbox.hooks")
if err != nil {
t.Skipf("Skipping test: sandbox hooks assistant not available: %v", err)
}
require.NotNil(t, ast.HookScript, "HookScript should be loaded")
// Create context
ctx := newSandboxE2EContext("sandbox-ctx-access", "tests.sandbox.hooks")
// Test Create Hook - it should have access to ctx.sandbox
messages := []context.Message{
{Role: context.RoleUser, Content: "test sandbox context access"},
}
// Execute Create hook directly
// This tests that the hook runs without error (sandbox operations tested within)
opts := &context.Options{}
response, _, err := ast.HookScript.Create(ctx, messages, opts)
// The hook might fail if sandbox isn't initialized yet (that's done in Stream)
// But we can at least verify the hook exists and can be called
if err != nil {
// If the error is about sandbox not being available, that's expected
// because we haven't initialized the sandbox yet
if strings.Contains(err.Error(), "sandbox") {
t.Logf("Expected error: sandbox not available in direct hook call: %v", err)
} else {
t.Fatalf("Unexpected error: %v", err)
}
}
// Response might be nil, that's okay
t.Logf("Create hook response: %v", response)
t.Log("✓ Sandbox context access test passed")
}
// TestSandboxMCPToolCall tests that Claude actually calls MCP tools via IPC
// This test specifically asks Claude to use the echo tool and verifies the result
func TestSandboxMCPToolCall(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox MCP tool call test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the full sandbox assistant (has MCP echo tool)
ast, err := assistant.Get("tests.sandbox.full")
if err != nil {
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
}
// Verify MCP is configured with echo tools
require.NotNil(t, ast.MCP, "MCP should be configured")
require.NotEmpty(t, ast.MCP.Servers, "MCP servers should be configured")
t.Logf("✓ MCP configured with server: %s, tools: %v",
ast.MCP.Servers[0].ServerID, ast.MCP.Servers[0].Tools)
// Create context
ctx := newSandboxE2EContext("sandbox-mcp-tool", "tests.sandbox.full")
// Explicit prompt to use echo tool
// This tells Claude to use the MCP tool specifically
messages := []context.Message{
{
Role: context.RoleUser,
Content: `Please use the 'ping' MCP tool to send a ping with message "MCP_TEST_SUCCESS".
Just call the tool and show me the result. Do not explain, just use the tool.`,
},
}
// Collect all response content
var responseContent strings.Builder
// Execute stream
response, err := ast.Stream(ctx, messages)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
require.NotNil(t, response, "Response should not be nil")
// Get the response content
fullResponse := ""
if response.Completion != nil && response.Completion.Content != nil {
if contentStr, ok := response.Completion.Content.(string); ok {
fullResponse = contentStr
responseContent.WriteString(contentStr)
}
}
t.Logf("Claude response: %s", fullResponse)
// Check if Claude acknowledged using the tool or returned tool results
// The response should contain either:
// 1. Evidence of tool call (tool_use block in response)
// 2. The ping result "pong" or "MCP_TEST_SUCCESS"
// 3. Some indication that it attempted to use the MCP tool
hasToolEvidence := strings.Contains(fullResponse, "pong") ||
strings.Contains(fullResponse, "MCP_TEST_SUCCESS") ||
strings.Contains(fullResponse, "ping") ||
strings.Contains(fullResponse, "tool")
if hasToolEvidence {
t.Log("✓ Claude appears to have used the MCP tool")
} else {
t.Logf("⚠ Claude response does not clearly show MCP tool usage")
t.Logf("Response: %s", fullResponse)
}
// At minimum, verify we got a response
if fullResponse == "" {
t.Log("⚠ Response content is empty")
}
t.Log("✓ Sandbox MCP tool call test completed")
}
// TestSandboxMCPEchoTool tests the echo MCP tool specifically
// This test uses a more explicit prompt to force tool usage
func TestSandboxMCPEchoTool(t *testing.T) {
if testing.Short() {
t.Skip("Skipping sandbox MCP echo test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the full sandbox assistant
ast, err := assistant.Get("tests.sandbox.full")
if err != nil {
t.Skipf("Skipping test: full sandbox assistant not available: %v", err)
}
// Create context
ctx := newSandboxE2EContext("sandbox-mcp-echo", "tests.sandbox.full")
// Very explicit prompt for echo tool
messages := []context.Message{
{
Role: context.RoleUser,
Content: `Call the 'echo' MCP tool with message "ECHO_VERIFICATION_12345" and uppercase=true.
Show me the exact response from the tool.`,
},
}
response, err := ast.Stream(ctx, messages)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "Docker") ||
strings.Contains(errStr, "sandbox") ||
strings.Contains(errStr, "container") ||
strings.Contains(errStr, "image") {
t.Skipf("Skipping test: Docker/sandbox not available: %v", err)
}
t.Fatalf("Stream failed: %v", err)
}
require.NotNil(t, response)
fullResponse := ""
if response.Completion != nil && response.Completion.Content != nil {
if contentStr, ok := response.Completion.Content.(string); ok {
fullResponse = contentStr
}
}
t.Logf("Claude response for echo tool: %s", fullResponse)
// The echo tool with uppercase=true should return "ECHO_VERIFICATION_12345"
// Check if this appears in the response
if strings.Contains(fullResponse, "ECHO_VERIFICATION_12345") {
t.Log("✓ MCP echo tool executed successfully - found verification string in response")
} else if strings.Contains(fullResponse, "echo") || strings.Contains(fullResponse, "ECHO") {
t.Log("✓ MCP echo tool appears to have been used (found 'echo' in response)")
} else {
t.Logf("⚠ Could not verify echo tool execution. Response: %s", fullResponse)
}
t.Log("✓ Sandbox MCP echo tool test completed")
}
// TestSandboxLoadConfiguration verifies that sandbox assistants load correctly
func TestSandboxLoadConfiguration(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
testCases := []struct {
name string
assistantID string
expectSandbox bool
expectMCP bool
expectHooks bool
}{
{
name: "BasicSandbox",
assistantID: "tests.sandbox.basic",
expectSandbox: true,
expectMCP: false,
expectHooks: false,
},
{
name: "HooksSandbox",
assistantID: "tests.sandbox.hooks",
expectSandbox: true,
expectMCP: false,
expectHooks: true,
},
{
name: "FullSandbox",
assistantID: "tests.sandbox.full",
expectSandbox: true,
expectMCP: true,
expectHooks: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ast, err := assistant.Get(tc.assistantID)
if err != nil {
t.Skipf("Skipping: assistant %s not available: %v", tc.assistantID, err)
}
// Check sandbox
if tc.expectSandbox {
require.NotNil(t, ast.Sandbox, "Expected sandbox to be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
t.Logf("✓ %s: Sandbox configured with command=%s", tc.name, ast.Sandbox.Command)
}
// Check MCP
if tc.expectMCP {
require.NotNil(t, ast.MCP, "Expected MCP to be configured")
assert.True(t, len(ast.MCP.Servers) > 0, "Expected at least one MCP server")
t.Logf("✓ %s: MCP configured with %d servers", tc.name, len(ast.MCP.Servers))
}
// Check hooks
if tc.expectHooks {
require.NotNil(t, ast.HookScript, "Expected hooks to be loaded")
t.Logf("✓ %s: Hooks loaded", tc.name)
}
})
}
}

View file

@ -1,188 +0,0 @@
package assistant_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/agent/assistant"
agentContext "github.com/yaoapp/yao/agent/context"
agentsandbox "github.com/yaoapp/yao/agent/sandbox"
"github.com/yaoapp/yao/agent/sandbox/claude"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestSandboxOptionsBuilding tests that sandbox options are correctly built from assistant config
func TestSandboxOptionsBuilding(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure connectors are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
// Load the full test assistant
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify sandbox is configured
require.NotNil(t, ast.Sandbox)
assert.Equal(t, "claude", ast.Sandbox.Command)
assert.Equal(t, "5m", ast.Sandbox.Timeout)
// Verify arguments are set
require.NotNil(t, ast.Sandbox.Arguments)
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
// Verify MCP configuration
require.NotNil(t, ast.MCP)
assert.Len(t, ast.MCP.Servers, 1)
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID)
t.Logf("Sandbox config: command=%s, timeout=%s", ast.Sandbox.Command, ast.Sandbox.Timeout)
t.Logf("Sandbox arguments: %v", ast.Sandbox.Arguments)
t.Logf("MCP servers: %v", ast.MCP.Servers)
}
// TestClaudeCommandBuilding tests that Claude CLI commands are correctly built
func TestClaudeCommandBuilding(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Create test messages
messages := []agentContext.Message{
{Role: "system", Content: "You are a helpful coding assistant."},
{Role: "user", Content: "Hello, how are you?"},
}
// Create options similar to what buildSandboxOptions would produce
opts := &claude.Options{
Command: "claude",
UserID: "test-user",
ChatID: "test-chat",
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
ConnectorKey: "test-api-key",
Model: "ep-xxxxx",
Arguments: map[string]interface{}{
"max_turns": 10,
"permission_mode": "acceptEdits",
},
}
// Build the command
cmd, env, err := claude.BuildCommand(messages, opts)
require.NoError(t, err)
// Verify command structure
// Command is now: ["bash", "-c", "cat << 'INPUTEOF' | claude -p ... INPUTEOF"]
assert.NotEmpty(t, cmd)
assert.Equal(t, "bash", cmd[0], "Command should start with bash")
assert.Equal(t, "-c", cmd[1], "Second arg should be -c")
assert.Contains(t, cmd[2], "claude -p", "Bash command should contain claude -p")
assert.Contains(t, cmd[2], "--permission-mode", "Should include permission mode")
assert.Contains(t, cmd[2], "--input-format", "Should include input-format flag")
assert.Contains(t, cmd[2], "--output-format", "Should include output-format flag")
assert.Contains(t, cmd[2], "--verbose", "Should include verbose flag")
assert.Contains(t, cmd[2], "stream-json", "Should use stream-json format")
assert.Contains(t, cmd[2], "INPUTEOF", "Should use heredoc for input")
t.Logf("Built command: %v", cmd)
// Verify environment variables (claude-proxy mode)
assert.NotEmpty(t, env)
assert.Equal(t, "http://127.0.0.1:3456", env["ANTHROPIC_BASE_URL"], "Should set proxy base URL")
assert.Equal(t, "dummy", env["ANTHROPIC_API_KEY"], "Should set dummy API key for proxy")
// max_turns is passed via CLI flag
// system prompt is written to file via heredoc, then referenced via --append-system-prompt-file
assert.Contains(t, cmd[2], "--max-turns", "Should include max-turns flag")
assert.Contains(t, cmd[2], "cat << 'PROMPTEOF' > /tmp/.system-prompt.txt", "Should use heredoc for system prompt")
assert.Contains(t, cmd[2], "--append-system-prompt-file", "Should include append-system-prompt-file flag")
assert.Contains(t, cmd[2], "You are a helpful coding assistant", "Command should contain system prompt")
t.Logf("Built environment: %v", env)
}
// TestClaudeProxyConfigBuilding tests that claude-proxy config is correctly built
func TestClaudeProxyConfigBuilding(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
opts := &claude.Options{
ConnectorHost: "https://ark.cn-beijing.volces.com/api/v3",
ConnectorKey: "test-api-key",
Model: "ep-xxxxx",
}
configJSON, err := claude.BuildProxyConfig(opts)
require.NoError(t, err)
require.NotEmpty(t, configJSON)
t.Logf("Proxy config: %s", string(configJSON))
// Verify the JSON contains expected fields for claude-proxy
assert.Contains(t, string(configJSON), "backend")
assert.Contains(t, string(configJSON), "api_key")
assert.Contains(t, string(configJSON), "model")
assert.Contains(t, string(configJSON), "test-api-key")
assert.Contains(t, string(configJSON), "ep-xxxxx")
// Backend URL should end with /chat/completions
assert.Contains(t, string(configJSON), "/chat/completions")
}
// TestDefaultImageSelection tests that default images are correctly selected
func TestDefaultImageSelection(t *testing.T) {
tests := []struct {
command string
expectedImage string
}{
{"claude", "yaoapp/sandbox-claude:latest"},
{"cursor", "yaoapp/sandbox-cursor:latest"},
{"unknown", ""},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
image := agentsandbox.DefaultImage(tt.command)
assert.Equal(t, tt.expectedImage, image)
})
}
}
// TestSandboxCommandValidation tests that command validation works correctly
func TestSandboxCommandValidation(t *testing.T) {
tests := []struct {
command string
valid bool
}{
{"claude", true},
{"cursor", true},
{"invalid", false},
{"", false},
}
for _, tt := range tests {
t.Run(tt.command, func(t *testing.T) {
result := agentsandbox.IsValidCommand(tt.command)
assert.Equal(t, tt.valid, result)
})
}
}
// TestHasSandboxMethod tests the HasSandbox method on Assistant
func TestHasSandboxMethod(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Test assistant with sandbox
astWithSandbox, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
require.NoError(t, err)
assert.True(t, astWithSandbox.HasSandbox(), "Assistant with sandbox config should return true")
// Test assistant without sandbox
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/simple-greeting")
require.NoError(t, err)
assert.False(t, astWithoutSandbox.HasSandbox(), "Assistant without sandbox config should return false")
}

View file

@ -1,319 +0,0 @@
package assistant_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent"
"github.com/yaoapp/yao/agent/assistant"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestLoadSandboxBasicAssistant tests loading the basic sandbox test assistant
func TestLoadSandboxBasicAssistant(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
ast, err := assistant.LoadPath("/assistants/tests/sandbox/basic")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify basic fields
assert.Equal(t, "tests.sandbox.basic", ast.ID)
assert.Equal(t, "Sandbox Basic Test", ast.Name)
assert.Equal(t, "deepseek.v3", ast.Connector)
// Verify sandbox configuration
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
assert.Equal(t, "5m", ast.Sandbox.Timeout)
// Verify HasSandbox returns true
assert.True(t, ast.HasSandbox(), "HasSandbox should return true")
}
// TestLoadSandboxHooksAssistant tests loading the hooks sandbox test assistant
func TestLoadSandboxHooksAssistant(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
ast, err := assistant.LoadPath("/assistants/tests/sandbox/hooks")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify basic fields
assert.Equal(t, "tests.sandbox.hooks", ast.ID)
assert.Equal(t, "Sandbox Hooks Test", ast.Name)
assert.Equal(t, "deepseek.v3", ast.Connector)
// Verify sandbox configuration
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
// Verify hooks are loaded
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
}
// TestLoadSandboxFullAssistant tests loading the full sandbox test assistant with MCPs and Skills
func TestLoadSandboxFullAssistant(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure MCPs are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify basic fields
assert.Equal(t, "tests.sandbox.full", ast.ID)
assert.Equal(t, "Sandbox Full Test", ast.Name)
assert.Equal(t, "deepseek.v3", ast.Connector)
// Verify sandbox configuration
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
assert.Equal(t, "5m", ast.Sandbox.Timeout)
// Verify sandbox arguments (command-specific options)
require.NotNil(t, ast.Sandbox.Arguments, "Sandbox arguments should be configured")
assert.Equal(t, float64(10), ast.Sandbox.Arguments["max_turns"])
assert.Equal(t, "acceptEdits", ast.Sandbox.Arguments["permission_mode"])
// Verify MCP configuration
require.NotNil(t, ast.MCP, "MCP should be configured")
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should be configured")
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
assert.Equal(t, "echo", ast.MCP.Servers[0].ServerID, "MCP server ID should be 'echo'")
assert.Contains(t, ast.MCP.Servers[0].Tools, "ping", "MCP tools should contain 'ping'")
assert.Contains(t, ast.MCP.Servers[0].Tools, "echo", "MCP tools should contain 'echo'")
// Verify hooks are loaded
assert.NotNil(t, ast.HookScript, "HookScript should be loaded")
}
// TestSandboxConfigValidation tests sandbox configuration validation
func TestSandboxConfigValidation(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
tests := []struct {
name string
path string
hasError bool
}{
{
name: "Basic sandbox config",
path: "/assistants/tests/sandbox/basic",
hasError: false,
},
{
name: "Hooks sandbox config",
path: "/assistants/tests/sandbox/hooks",
hasError: false,
},
{
name: "Full sandbox config with MCPs",
path: "/assistants/tests/sandbox/full",
hasError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ast, err := assistant.LoadPath(tt.path)
if tt.hasError {
assert.Error(t, err)
return
}
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.Sandbox)
assert.NotEmpty(t, ast.Sandbox.Command)
})
}
}
// TestSkillsDirectoryResolution tests that skills directory exists and has correct structure
// Note: Skills are auto-discovered from skills/ directory, not stored in AssistantModel
func TestSkillsDirectoryResolution(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Get app root from environment
appRoot := os.Getenv("YAO_ROOT")
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
// Verify assistant path is set
assert.NotEmpty(t, ast.Path, "Assistant path should be set")
// Build expected skills directory path
// ast.Path is like "/assistants/tests/sandbox/full"
expectedSkillsDir := filepath.Join(appRoot, ast.Path, "skills")
// Verify skills directory exists
info, err := os.Stat(expectedSkillsDir)
require.NoError(t, err, "Skills directory should exist: %s", expectedSkillsDir)
assert.True(t, info.IsDir(), "Skills path should be a directory")
// Verify skills directory structure
entries, err := os.ReadDir(expectedSkillsDir)
require.NoError(t, err, "Should be able to read skills directory")
// Find echo-test skill
var foundEchoTest bool
for _, entry := range entries {
if entry.IsDir() && entry.Name() == "echo-test" {
foundEchoTest = true
// Verify SKILL.md exists (required)
skillMdPath := filepath.Join(expectedSkillsDir, "echo-test", "SKILL.md")
_, err := os.Stat(skillMdPath)
assert.NoError(t, err, "SKILL.md should exist")
// Verify scripts directory exists (optional but we created it)
scriptsDir := filepath.Join(expectedSkillsDir, "echo-test", "scripts")
_, err = os.Stat(scriptsDir)
assert.NoError(t, err, "scripts directory should exist")
// Verify echo.sh exists
echoShPath := filepath.Join(scriptsDir, "echo.sh")
_, err = os.Stat(echoShPath)
assert.NoError(t, err, "echo.sh should exist")
break
}
}
assert.True(t, foundEchoTest, "echo-test skill should exist in skills directory")
}
// TestMCPConfiguration tests that MCP is correctly loaded for sandbox assistant
func TestMCPConfiguration(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure MCPs are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify MCP configuration structure
require.NotNil(t, ast.MCP, "MCP should not be nil")
require.NotNil(t, ast.MCP.Servers, "MCP.Servers should not be nil")
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server configured")
// Verify echo server configuration
echoServer := ast.MCP.Servers[0]
assert.Equal(t, "echo", echoServer.ServerID, "Server ID should be 'echo'")
assert.Len(t, echoServer.Tools, 3, "Should have 3 tools configured")
assert.Contains(t, echoServer.Tools, "ping")
assert.Contains(t, echoServer.Tools, "echo")
assert.Contains(t, echoServer.Tools, "status")
}
// TestBuildMCPConfigForSandbox tests that MCP configuration is correctly built for sandbox
func TestBuildMCPConfigForSandbox(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure MCPs are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
require.NotNil(t, ast.MCP, "MCP configuration should exist")
// Create a mock context for the test
ctx := agentContext.New(context.Background(), nil, "test-mcp-config-build")
// Call BuildMCPConfigForSandbox and verify the result
mcpConfig, err := ast.BuildMCPConfigForSandbox(ctx)
require.NoError(t, err, "BuildMCPConfigForSandbox should not error")
require.NotEmpty(t, mcpConfig, "MCP config should not be empty")
t.Logf("MCP config JSON: %s", string(mcpConfig))
// Parse and verify the JSON structure
var config map[string]interface{}
err = json.Unmarshal(mcpConfig, &config)
require.NoError(t, err, "MCP config should be valid JSON")
// Verify mcpServers key exists
mcpServers, ok := config["mcpServers"].(map[string]interface{})
require.True(t, ok, "mcpServers should be a map")
require.NotEmpty(t, mcpServers, "mcpServers should not be empty")
// Verify "yao" server exists (single server using yao-bridge for IPC)
yaoServer, ok := mcpServers["yao"].(map[string]interface{})
require.True(t, ok, "yao server should exist in mcpServers")
// Verify server structure - uses yao-bridge to connect to IPC socket
assert.Equal(t, "yao-bridge", yaoServer["command"], "command should be yao-bridge")
args, ok := yaoServer["args"].([]interface{})
require.True(t, ok, "args should be an array")
require.Len(t, args, 1, "args should have 1 element")
assert.Equal(t, "/tmp/yao.sock", args[0], "first arg should be IPC socket path")
t.Logf("✓ MCP config verified: uses yao-bridge with IPC socket /tmp/yao.sock")
}
// TestSandboxMCPAndSkillsOptions tests that sandbox options include MCP and Skills
func TestSandboxMCPAndSkillsOptions(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Load agent to ensure MCPs are available
err := agent.Load(config.Conf)
require.NoError(t, err, "agent.Load should succeed")
ast, err := assistant.LoadPath("/assistants/tests/sandbox/full")
require.NoError(t, err)
require.NotNil(t, ast)
// Verify sandbox configuration is present
require.NotNil(t, ast.Sandbox, "Sandbox should be configured")
assert.Equal(t, "claude", ast.Sandbox.Command)
// Verify MCP is configured (will be passed to sandbox)
require.NotNil(t, ast.MCP, "MCP should be configured")
assert.Len(t, ast.MCP.Servers, 1, "Should have 1 MCP server")
// Verify skills directory exists
appRoot := os.Getenv("YAO_ROOT")
require.NotEmpty(t, appRoot, "YAO_ROOT should be set")
skillsDir := filepath.Join(appRoot, ast.Path, "skills")
info, err := os.Stat(skillsDir)
require.NoError(t, err, "Skills directory should exist")
assert.True(t, info.IsDir(), "Skills should be a directory")
// Verify echo-test skill exists
echoTestDir := filepath.Join(skillsDir, "echo-test")
info, err = os.Stat(echoTestDir)
require.NoError(t, err, "echo-test skill should exist")
assert.True(t, info.IsDir(), "echo-test should be a directory")
// Verify SKILL.md exists
skillMd := filepath.Join(echoTestDir, "SKILL.md")
_, err = os.Stat(skillMd)
require.NoError(t, err, "SKILL.md should exist")
}

View file

@ -1,354 +0,0 @@
package assistant
import (
stdContext "context"
"fmt"
"log"
"os"
"path/filepath"
"time"
"github.com/yaoapp/gou/connector"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/agent/output/message"
sandboxv2 "github.com/yaoapp/yao/agent/sandbox/v2"
sandboxTypes "github.com/yaoapp/yao/agent/sandbox/v2/types"
store "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/llmprovider"
infraV2 "github.com/yaoapp/yao/sandbox/v2"
traceTypes "github.com/yaoapp/yao/trace/types"
"github.com/yaoapp/yao/workspace"
)
// HasSandboxV2 returns true if the assistant has a V2 sandbox configuration.
func (ast *Assistant) HasSandboxV2() bool {
return ast.SandboxV2 != nil
}
// sandboxV2InitResult bundles everything returned by initSandboxV2.
type sandboxV2InitResult struct {
Runner sandboxTypes.Runner
Computer infraV2.Computer
Config *sandboxTypes.SandboxConfig
Cleanup func()
LoadingMsgID string
Roles map[string]connector.Connector
}
// initSandboxV2 initializes the V2 sandbox: obtains a Computer, gets a Runner,
// resolves the role matrix, runs Prepare, and returns the result.
//
// A shallow copy of ast.SandboxV2 is made so that concurrent requests to the
// same assistant each get their own mutable config (Owner, ID, NodeID, etc.).
func (ast *Assistant) initSandboxV2(ctx *context.Context, opts *context.Options) (*sandboxV2InitResult, error) {
cfgCopy := *ast.SandboxV2
cfg := &cfgCopy
manager := infraV2.M()
loadingMsg := &message.Message{
Type: message.TypeLoading,
Props: map[string]any{
"message": i18n.T(ctx.Locale, "sandbox.preparing"),
},
}
loadingMsgID, _ := ctx.SendStream(loadingMsg)
stdCtx := ctx.Context
// 1. Resolve connector (before Computer so proxy env vars can be injected).
conn, _, err := ast.GetConnector(ctx, opts)
if err != nil && cfg.Runner.Name != "yao" {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("get connector: %w", err)
}
// 1b. Resolve role matrix once; passed to both Prepare and Stream.
roles := resolveRoles(conn, ctx.Authorized)
// 2. Build human-readable DisplayName from real Agent name + Workspace name.
cfg.DisplayName = buildBoxDisplayName(ctx, ast.ID, ast.Name)
// 2.5. Image existence check + pull (for box mode).
if cfg.Computer.Image != "" && manager != nil {
nodeID, kind, _ := sandboxv2.ResolveNodeID(ctx, cfg, manager)
if kind == "box" && nodeID != "" {
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
exists, existsErr := manager.ImageExists(stdCtx, nodeID, cfg.Computer.Image)
if existsErr != nil {
log.Printf("[sandbox/v2] image exists check failed on node %s: %v", nodeID, existsErr)
}
if existsErr == nil && !exists {
updateLoadingV2(ctx, loadingMsgID, "sandbox.pulling_image")
ch, pullErr := manager.PullImage(stdCtx, nodeID, cfg.Computer.Image, infraV2.ImagePullOptions{})
if pullErr != nil {
log.Printf("[sandbox/v2] image pull failed on node %s: %v (will retry in Create)", nodeID, pullErr)
} else if ch != nil {
for p := range ch {
if p.Error != "" {
log.Printf("[sandbox/v2] image pull progress error: %s", p.Error)
break
}
}
}
}
}
}
// 3. Obtain Computer.
updateLoadingV2(ctx, loadingMsgID, "sandbox.starting")
computer, identifier, err := sandboxv2.GetComputer(ctx, cfg, manager)
if err != nil {
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("getComputer failed: %w", err)
}
_ = identifier
// 4. Get Runner.
runner, err := sandboxv2.Get(cfg.Runner.Name)
if err != nil {
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("get runner %q: %w", cfg.Runner.Name, err)
}
// 5. Resolve assistant directory and skills subdirectory.
assistantDir := ""
skillsDir := ""
if ast.Path != "" {
assistantDir = filepath.Join(config.Conf.AppSource, ast.Path)
dir := filepath.Join(assistantDir, "skills")
if info, e := os.Stat(dir); e == nil && info.IsDir() {
skillsDir = dir
}
}
// 6. Convert MCP servers.
var mcpServers []sandboxTypes.MCPServer
if ast.MCP != nil {
for _, s := range ast.MCP.Servers {
mcpServers = append(mcpServers, sandboxTypes.MCPServer{
ServerID: s.ServerID,
Resources: s.Resources,
Tools: s.Tools,
})
}
}
// 7. Runner.Prepare (standard context).
err = runner.Prepare(stdCtx, &sandboxTypes.PrepareRequest{
Computer: computer,
Config: cfg,
Connector: conn,
Roles: roles,
AssistantID: ast.ID,
SkillsDir: skillsDir,
AssistantDir: assistantDir,
MCPServers: mcpServers,
ConfigHash: ast.ConfigHash,
RunSteps: sandboxv2.RunPrepareSteps,
})
if err != nil {
runner.Cleanup(stdCtx, computer)
sandboxv2.LifecycleAction(stdCtx, cfg, computer, manager)
closeLoadingV2(ctx, loadingMsgID, "sandbox.failed")
return nil, fmt.Errorf("runner.Prepare: %w", err)
}
ctx.SetComputer(computer)
cleanup := func() {
cleanCtx, cancel := stdContext.WithTimeout(stdContext.Background(), 5*time.Second)
defer cancel()
runner.Cleanup(cleanCtx, computer)
sandboxv2.LifecycleAction(cleanCtx, cfg, computer, manager)
}
return &sandboxV2InitResult{
Runner: runner,
Computer: computer,
Config: cfg,
Cleanup: cleanup,
LoadingMsgID: loadingMsgID,
Roles: roles,
}, nil
}
// sandboxV2StreamParams groups arguments for executeSandboxV2Stream.
type sandboxV2StreamParams struct {
Messages []context.Message
AgentNode traceTypes.Node
Handler message.StreamFunc
Runner sandboxTypes.Runner
Computer infraV2.Computer
Config *sandboxTypes.SandboxConfig
LoadingMsgID string
Options *context.Options
Roles map[string]connector.Connector
}
// executeSandboxV2Stream calls the V2 Runner.Stream and wraps it in the
// standard completion response.
func (ast *Assistant) executeSandboxV2Stream(
ctx *context.Context, p *sandboxV2StreamParams,
) (*context.CompletionResponse, error) {
_ = p.AgentNode
cfg := p.Config
manager := infraV2.M()
// Build system prompt (parse $CTX variables the same way as buildSystemPrompts).
var systemPrompt string
if len(ast.Prompts) > 0 {
ctxVars := ast.buildContextVariables(ctx)
parsed := store.Prompts(ast.Prompts).Parse(ctxVars)
for _, pr := range parsed {
if pr.Role == "system" && pr.Content != "" {
systemPrompt = pr.Content
break
}
}
}
// Resolve connector for Stream (respects user-selected connector via opts).
conn, _, _ := ast.GetConnector(ctx, p.Options)
var tok *sandboxTypes.SandboxToken
if ctx.Authorized != nil {
var err error
tok, err = sandboxv2.IssueSandboxToken(ctx.Authorized.TeamID, ctx.Authorized.UserID)
if err != nil {
return nil, fmt.Errorf("issue sandbox token: %w", err)
}
}
streamReq := &sandboxTypes.StreamRequest{
Computer: p.Computer,
Config: cfg,
Connector: conn,
Roles: p.Roles,
AssistantID: ast.ID,
Messages: p.Messages,
SystemPrompt: systemPrompt,
ChatID: ctx.ChatID,
Token: tok,
Logger: ctx.Logger,
UserExplicit: p.Options != nil && p.Options.Connector != "",
Locale: ctx.Locale,
}
execReq := &sandboxv2.ExecuteRequest{
Computer: p.Computer,
Runner: p.Runner,
Config: cfg,
StreamReq: streamReq,
Manager: manager,
LoadingMsgID: p.LoadingMsgID,
}
return sandboxv2.ExecuteSandboxStream(ctx, execReq, p.Handler)
}
// resolveRoles builds the role → connector map using the llmprovider role system.
// The primary connector (user-selected or system default) becomes "default";
// other roles (heavy, light, vision) are fetched from llmprovider settings.
func resolveRoles(conn connector.Connector, identity llmprovider.Identity) map[string]connector.Connector {
roles := map[string]connector.Connector{}
if conn != nil {
roles["default"] = conn
}
if llmprovider.Global == nil || identity == nil {
return roles
}
for _, role := range []string{"heavy", "light", "vision"} {
if c, err := llmprovider.Global.GetRoleModelBy(role, identity); err == nil {
roles[role] = c
}
}
return roles
}
// initStandaloneWorkspace loads the workspace FS into context when no sandbox
// is configured but the user selected a workspace (metadata["workspace_id"]).
func (ast *Assistant) initStandaloneWorkspace(ctx *context.Context) {
if ctx.Metadata == nil {
return
}
wsID, _ := ctx.Metadata["workspace_id"].(string)
if wsID == "" {
return
}
stdCtx := ctx.Context
wsFS, err := workspace.M().FS(stdCtx, wsID)
if err != nil {
log.Printf("[assistant] initStandaloneWorkspace: failed to load workspace %s: %v", wsID, err)
return
}
ctx.SetWorkspace(wsFS)
}
// buildBoxDisplayName constructs a human-readable display name for a Box
// using the locale-resolved Agent name and Workspace name (matching the UI list pages).
func buildBoxDisplayName(ctx *context.Context, assistantID, rawName string) string {
agentName := i18n.Tr(assistantID, ctx.Locale, rawName)
wsName := ""
if ctx.Metadata != nil {
if wsID, ok := ctx.Metadata["workspace_id"].(string); ok && wsID != "" {
if wsm := workspace.M(); wsm != nil {
if ws, err := wsm.Get(ctx.Context, wsID); err == nil && ws != nil {
wsName = ws.Name
}
}
}
}
if agentName != "" && wsName != "" {
return agentName + " / " + wsName
}
if agentName != "" {
return agentName
}
if wsName != "" {
return wsName
}
return ""
}
func updateLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
if loadingMsgID == "" || ctx == nil || msgKey == "" {
return
}
msg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: map[string]any{
"message": i18n.T(ctx.Locale, msgKey),
},
}
ctx.Send(msg)
}
func closeLoadingV2(ctx *context.Context, loadingMsgID, msgKey string) {
if loadingMsgID == "" || ctx == nil {
return
}
props := map[string]any{"done": true}
if msgKey != "" {
props["message"] = i18n.T(ctx.Locale, msgKey)
} else {
props["message"] = ""
}
doneMsg := &message.Message{
MessageID: loadingMsgID,
Delta: true,
DeltaAction: message.DeltaReplace,
Type: message.TypeLoading,
Props: props,
}
ctx.Send(doneMsg)
}

View file

@ -1,391 +0,0 @@
package assistant
import (
"context"
"fmt"
"path/filepath"
"strings"
"sync"
"time"
"github.com/yaoapp/gou/application"
"github.com/yaoapp/gou/process"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/kun/exception"
"github.com/yaoapp/yao/agent/assistant/hook"
)
// scriptsMutex protects concurrent v8.Load calls and Scripts map access
var scriptsMutex sync.Mutex
// Execute execute the script
func (s *Script) Execute(ctx context.Context, method string, args ...interface{}) (interface{}, error) {
return s.ExecuteWithAuthorized(ctx, method, nil, args...)
}
// ExecuteWithAuthorized execute the script with authorized information
func (s *Script) ExecuteWithAuthorized(ctx context.Context, method string, authorized map[string]interface{}, args ...interface{}) (interface{}, error) {
if s == nil || s.Script == nil {
return nil, nil
}
scriptCtx, err := s.NewContext("", nil)
if err != nil {
return nil, err
}
defer scriptCtx.Close()
// Set authorized information if available
if authorized != nil {
scriptCtx.WithAuthorized(authorized)
}
// Call the method with provided arguments as-is
result, err := scriptCtx.CallWith(ctx, method, args...)
// Return error as-is (including "not defined" errors)
return result, err
}
// LoadScripts loads all scripts from a src directory path
// It scans for .ts and .js files (excluding index.ts which is the hook script)
// Returns the HookScript and a map of other scripts
func LoadScripts(srcDir string) (*hook.Script, map[string]*Script, error) {
// Check if src directory exists
exists, err := application.App.Exists(srcDir)
if err != nil {
return nil, nil, err
}
if !exists {
return nil, nil, nil // No src directory
}
var hookScript *hook.Script
scripts := make(map[string]*Script)
var loadErr error
// Walk through src directory to find all script files
exts := []string{"*.ts", "*.js"}
err = application.App.Walk(srcDir, func(root, file string, isdir bool) error {
if isdir {
return nil
}
// file is the full path, root is srcDir
// Get relative path for determining if it's index
relPath := strings.TrimPrefix(file, root+"/")
// Skip test files (*_test.ts, *_test.js)
if strings.HasSuffix(relPath, "_test.ts") || strings.HasSuffix(relPath, "_test.js") {
return nil
}
// Check if it's the root index.ts/js (hook script)
// Only src/index.ts is the hook script, not src/foo/index.ts
isRootIndex := relPath == "index.ts" || relPath == "index.js"
if isRootIndex {
scriptsMutex.Lock()
script, err := loadScriptFile(file)
scriptsMutex.Unlock()
if err != nil {
loadErr = fmt.Errorf("failed to load hook script %s: %w", file, err)
return loadErr
}
hookScript = script
} else {
// Generate script ID from relative path
scriptID := generateScriptID(file, root)
// Load the script (v8.Load is not thread-safe)
scriptsMutex.Lock()
script, err := loadScriptV8(file)
if err != nil {
scriptsMutex.Unlock()
loadErr = fmt.Errorf("failed to load script %s: %w", file, err)
return loadErr
}
scripts[scriptID] = &Script{Script: script}
scriptsMutex.Unlock()
}
return nil
}, exts...)
if loadErr != nil {
return nil, nil, loadErr
}
if err != nil {
return nil, nil, fmt.Errorf("failed to walk src directory: %w", err)
}
return hookScript, scripts, nil
}
// generateScriptID generates a script ID from file path
// Example: assistants/test/src/foo/bar/test.ts -> foo.bar.test
func generateScriptID(filePath string, srcDir string) string {
// Normalize path separators
filePath = filepath.ToSlash(filePath)
srcDir = filepath.ToSlash(srcDir)
// Remove src directory prefix
relPath := strings.TrimPrefix(filePath, srcDir+"/")
relPath = strings.TrimPrefix(relPath, "/")
// Remove file extension
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath))
// Replace path separators with dots
scriptID := strings.ReplaceAll(relPath, "/", ".")
return scriptID
}
// loadScriptFile loads a hook script from file
func loadScriptFile(file string) (*hook.Script, error) {
id := makeScriptID(file, "")
script, err := v8.Load(file, id)
if err != nil {
return nil, err
}
return &hook.Script{Script: script}, nil
}
// loadScriptFromSource loads a script from source code
// Uses MakeScriptInMemory which supports TypeScript syntax without file resolution
func loadScriptFromSource(source string, file string) (*v8.Script, error) {
script, err := v8.MakeScriptInMemory([]byte(source), file, 5*time.Second, true)
if err != nil {
return nil, err
}
return script, nil
}
// loadScriptV8 loads a v8.Script from file (used for non-hook scripts)
func loadScriptV8(file string) (*v8.Script, error) {
id := makeScriptID(file, "")
script, err := v8.Load(file, id)
if err != nil {
return nil, err
}
return script, nil
}
// makeScriptID generates the script ID for v8.Load
// Converts file path to a dot-separated ID
// Example: assistants/tests/fullfields/src/index.ts -> assistants.tests.fullfields.src.index
func makeScriptID(file string, root string) string {
// Remove root prefix if provided
id := file
if root != "" {
id = strings.TrimPrefix(file, root+"/")
}
// Remove extension
id = strings.TrimSuffix(id, filepath.Ext(id))
// Replace path separators with dots
id = strings.ReplaceAll(id, "/", ".")
id = strings.ReplaceAll(id, string(filepath.Separator), ".")
return id
}
// LoadScriptsFromData loads scripts from data map
// Handles script/scripts/source fields with priority: script > scripts > source > file system
func LoadScriptsFromData(data map[string]interface{}, assistantID string) (*hook.Script, map[string]*Script, error) {
// Priority 1: script field (hook script from string source)
if data["script"] != nil {
switch v := data["script"].(type) {
case string:
file := fmt.Sprintf("assistants/%s/src/index.ts", assistantID)
script, err := loadScriptFromSource(v, file)
if err != nil {
return nil, nil, err
}
hookScript := &hook.Script{Script: script}
// Load other scripts if provided
scripts, err := loadScriptsField(data["scripts"])
if err != nil {
return nil, nil, err
}
return hookScript, scripts, nil
case *hook.Script:
scripts, err := loadScriptsField(data["scripts"])
if err != nil {
return nil, nil, err
}
return v, scripts, nil
case *v8.Script:
scripts, err := loadScriptsField(data["scripts"])
if err != nil {
return nil, nil, err
}
return &hook.Script{Script: v}, scripts, nil
}
}
// Priority 2: scripts field (map of scripts)
if data["scripts"] != nil {
// First extract index if present
var hookScript *hook.Script
if scriptsMap, ok := data["scripts"].(map[string]interface{}); ok {
if indexSource, hasIndex := scriptsMap["index"]; hasIndex {
switch v := indexSource.(type) {
case string:
file := fmt.Sprintf("assistants/%s/src/index.ts", assistantID)
script, err := loadScriptFromSource(v, file)
if err != nil {
return nil, nil, err
}
hookScript = &hook.Script{Script: script}
case *Script:
hookScript = &hook.Script{Script: v.Script}
case *v8.Script:
hookScript = &hook.Script{Script: v}
}
}
}
// Then load other scripts (loadScriptsField automatically filters out index)
scripts, err := loadScriptsField(data["scripts"])
if err != nil {
return nil, nil, err
}
return hookScript, scripts, nil
}
// Priority 3: source field (legacy hook script from source)
if source, ok := data["source"].(string); ok && source != "" {
script, err := loadSource(source, assistantID)
if err != nil {
return nil, nil, err
}
return script, nil, nil
}
// Priority 4: file system (scan src directory)
srcDir := fmt.Sprintf("assistants/%s/src", assistantID)
return LoadScripts(srcDir)
}
// loadScriptsField parses scripts field from data
// Note: "index" is always filtered out as it's reserved for HookScript
func loadScriptsField(scriptsData interface{}) (map[string]*Script, error) {
if scriptsData == nil {
return nil, nil
}
scripts := make(map[string]*Script)
switch v := scriptsData.(type) {
case map[string]*Script:
for id, script := range v {
if id == "index" {
continue // Skip index
}
scripts[id] = script
}
return scripts, nil
case map[string]*v8.Script:
for id, script := range v {
if id == "index" {
continue // Skip index
}
scripts[id] = &Script{Script: script}
}
return scripts, nil
case map[string]interface{}:
for id, item := range v {
if id == "index" {
continue // Skip index
}
switch s := item.(type) {
case *Script:
scripts[id] = s
case *v8.Script:
scripts[id] = &Script{Script: s}
case string:
// Load script from source code
file := fmt.Sprintf("script_%s", id)
script, err := loadScriptFromSource(s, file)
if err != nil {
return nil, fmt.Errorf("failed to load script %s: %w", id, err)
}
scripts[id] = &Script{Script: script}
}
}
return scripts, nil
}
return nil, nil
}
// RegisterScripts registers all scripts as process handlers
// Handler naming: agents.<assistantID>.<scriptID>
func (ast *Assistant) RegisterScripts() error {
if len(ast.Scripts) == 0 {
return nil
}
assistantID := ast.ID
handlers := make(map[string]process.Handler)
for scriptID, script := range ast.Scripts {
// Create handler for this script
handlers[scriptID] = makeScriptHandler(script)
}
// Register the handler group dynamically
groupName := fmt.Sprintf("agents.%s", assistantID)
process.RegisterDynamicGroup(groupName, handlers)
return nil
}
// UnregisterScripts unregisters all scripts from process handlers
func (ast *Assistant) UnregisterScripts() error {
if len(ast.Scripts) == 0 {
return nil
}
assistantID := ast.ID
for scriptID := range ast.Scripts {
handlerID := fmt.Sprintf("agents.%s.%s", strings.ToLower(assistantID), strings.ToLower(scriptID))
delete(process.Handlers, handlerID)
}
return nil
}
// makeScriptHandler creates a process handler for a script
func makeScriptHandler(script *Script) process.Handler {
return func(p *process.Process) interface{} {
// Extract method name from process
method := p.Method
// Get arguments from process
args := p.Args
// Convert authorized info to map if available
var authorized map[string]interface{}
if p.Authorized != nil {
authorized = p.Authorized.AuthorizedToMap()
}
// Execute the script with authorized information
result, err := script.ExecuteWithAuthorized(p.Context, method, authorized, args...)
if err != nil {
exception.New(err.Error(), 500).Throw()
}
return result
}
}

View file

@ -1,210 +0,0 @@
package assistant_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/testutils"
)
func TestScriptsProcessFlow(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Get the mcpload assistant
assistantID := "tests.mcpload"
ast, err := assistant.Get(assistantID)
assert.NoError(t, err)
assert.NotNil(t, ast, "Assistant should be loaded")
// Check that scripts were loaded
assert.NotNil(t, ast.Scripts)
assert.Greater(t, len(ast.Scripts), 0, "Should have loaded at least one script")
// Verify tools.ts was loaded
toolsScript, hasTools := ast.Scripts["tools"]
assert.True(t, hasTools, "Should have loaded tools script")
assert.NotNil(t, toolsScript)
// Register scripts as process handlers
err = ast.RegisterScripts()
assert.NoError(t, err)
// Test 1: Call Hello function
t.Run("CallHelloFunction", func(t *testing.T) {
handlerID := "agents.tests.mcpload.tools"
handler, exists := process.Handlers[handlerID]
assert.True(t, exists, "Handler should be registered")
p := &process.Process{
ID: handlerID + ".Hello",
Method: "Hello",
Args: []interface{}{map[string]interface{}{"name": "Yao"}},
Context: context.Background(),
}
result := handler(p)
assert.NotNil(t, result)
resultStr, ok := result.(string)
assert.True(t, ok, "Result should be a string")
assert.Contains(t, resultStr, "Hello, Yao")
})
// Test 2: Call Ping function
t.Run("CallPingFunction", func(t *testing.T) {
handlerID := "agents.tests.mcpload.tools"
handler, exists := process.Handlers[handlerID]
assert.True(t, exists, "Handler should be registered")
p := &process.Process{
ID: handlerID + ".Ping",
Method: "Ping",
Args: []interface{}{map[string]interface{}{"message": "test"}},
Context: context.Background(),
}
result := handler(p)
assert.NotNil(t, result)
resultMap, ok := result.(map[string]interface{})
assert.True(t, ok, "Result should be a map")
assert.Equal(t, "test", resultMap["message"])
assert.Contains(t, resultMap["echo"], "Pong")
})
// Test 3: Call Calculate function
t.Run("CallCalculateFunction", func(t *testing.T) {
handlerID := "agents.tests.mcpload.tools"
handler, exists := process.Handlers[handlerID]
assert.True(t, exists, "Handler should be registered")
p := &process.Process{
ID: handlerID + ".Calculate",
Method: "Calculate",
Args: []interface{}{map[string]interface{}{
"operation": "add",
"a": float64(10),
"b": float64(5),
}},
Context: context.Background(),
}
result := handler(p)
assert.NotNil(t, result)
resultMap, ok := result.(map[string]interface{})
assert.True(t, ok, "Result should be a map")
assert.Equal(t, float64(15), resultMap["result"])
})
// Test 4: Unregister scripts
t.Run("UnregisterScripts", func(t *testing.T) {
err := ast.UnregisterScripts()
assert.NoError(t, err)
// Verify handlers are removed
handlerID := "agents.tests.mcpload.tools"
_, exists := process.Handlers[handlerID]
assert.False(t, exists, "Handler should be unregistered")
})
}
func TestScriptsProcessUsing(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Get the mcpload assistant
assistantID := "tests.mcpload"
ast, err := assistant.Get(assistantID)
assert.NoError(t, err)
assert.NotNil(t, ast)
// Register scripts
err = ast.RegisterScripts()
assert.NoError(t, err)
defer ast.UnregisterScripts()
// Test 1: Call Hello using process.New().Execute()
t.Run("ProcessHello", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.Hello", map[string]interface{}{
"name": "Yao",
})
err := proc.Execute()
assert.NoError(t, err)
result := proc.Value()
assert.NotNil(t, result)
resultStr, ok := result.(string)
assert.True(t, ok, "Result should be a string")
assert.Contains(t, resultStr, "Hello, Yao")
})
// Test 2: Call Ping using process.New().Execute()
t.Run("ProcessPing", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.Ping", map[string]interface{}{
"message": "test message",
})
err := proc.Execute()
assert.NoError(t, err)
result := proc.Value()
assert.NotNil(t, result)
resultMap, ok := result.(map[string]interface{})
assert.True(t, ok, "Result should be a map")
assert.Equal(t, "test message", resultMap["message"])
assert.Contains(t, resultMap["echo"], "Pong")
})
// Test 3: Call Calculate using process.New().Execute()
t.Run("ProcessCalculate", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.Calculate", map[string]interface{}{
"operation": "multiply",
"a": float64(6),
"b": float64(7),
})
err := proc.Execute()
assert.NoError(t, err)
result := proc.Value()
assert.NotNil(t, result)
resultMap, ok := result.(map[string]interface{})
assert.True(t, ok, "Result should be a map")
assert.Equal(t, float64(42), resultMap["result"])
assert.Equal(t, "multiply", resultMap["operation"])
})
}
func TestScriptsProcessError(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Get the mcpload assistant
assistantID := "tests.mcpload"
ast, err := assistant.Get(assistantID)
assert.NoError(t, err)
assert.NotNil(t, ast)
// Register scripts
err = ast.RegisterScripts()
assert.NoError(t, err)
defer ast.UnregisterScripts()
// Test calling non-existent method
t.Run("CallNonExistentMethod", func(t *testing.T) {
proc := process.New("agents.tests.mcpload.tools.NonExistent")
err := proc.Execute()
assert.NotNil(t, err, "Should return error when calling non-existent method")
assert.Contains(t, err.Error(), "Exception|500")
})
}

View file

@ -1,307 +0,0 @@
package assistant
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/process"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
)
// TestLoadScripts tests loading scripts from file system
// Note: These tests are commented out due to path format differences
// The functionality is tested by existing integration tests in the codebase
func TestLoadScriptsFromData(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
t.Run("LoadFromScriptField", func(t *testing.T) {
// Use JavaScript instead of TypeScript to avoid compilation path issues
data := map[string]interface{}{
"script": `function Create(ctx) { return null; }`,
}
// Need to provide a real assistant path for compilation
data["path"] = "assistants/tests/mcpload"
hookScript, scripts, err := LoadScriptsFromData(data, "tests.mcpload")
require.NoError(t, err)
assert.NotNil(t, hookScript, "HookScript should be loaded from script field")
assert.Nil(t, scripts, "Scripts should be nil when only script field is provided")
t.Logf("✓ Successfully loaded from script field")
})
t.Run("LoadFromScriptsField", func(t *testing.T) {
data := map[string]interface{}{
"scripts": map[string]interface{}{
"tool1": `function tool1() { return "tool1"; }`,
"tool2": `function tool2() { return "tool2"; }`,
},
}
hookScript, scripts, err := LoadScriptsFromData(data, "test.assistant")
require.NoError(t, err)
assert.Nil(t, hookScript, "HookScript should be nil when no index in scripts")
require.NotNil(t, scripts, "Scripts should be loaded")
assert.Len(t, scripts, 2, "Should have 2 scripts")
assert.Contains(t, scripts, "tool1")
assert.Contains(t, scripts, "tool2")
t.Logf("✓ Successfully loaded from scripts field")
})
t.Run("LoadFromScriptsFieldWithIndex", func(t *testing.T) {
// Test that index is properly extracted and not present in Scripts map
// Note: We skip actual script compilation here to avoid path issues
data := map[string]interface{}{
"scripts": map[string]interface{}{
"tool1": `function tool1() { return "tool1"; }`,
"tool2": `function tool2() { return "tool2"; }`,
},
}
hookScript, scripts, err := LoadScriptsFromData(data, "test.assistant")
require.NoError(t, err)
// Without index in scripts field, hookScript should be nil
assert.Nil(t, hookScript, "HookScript should be nil when no index in scripts")
require.NotNil(t, scripts, "Scripts should be loaded")
assert.Len(t, scripts, 2, "Should have 2 scripts")
assert.Contains(t, scripts, "tool1")
assert.Contains(t, scripts, "tool2")
assert.NotContains(t, scripts, "index", "index should never be in Scripts map")
t.Logf("✓ Successfully loaded from scripts field, index properly filtered")
})
t.Run("LoadFromSourceField", func(t *testing.T) {
data := map[string]interface{}{
"source": `function Create(ctx) { return null; }`,
}
hookScript, scripts, err := LoadScriptsFromData(data, "test.assistant")
require.NoError(t, err)
assert.NotNil(t, hookScript, "HookScript should be loaded from source field")
assert.Nil(t, scripts, "Scripts should be nil when only source field is provided")
t.Logf("✓ Successfully loaded from source field")
})
t.Run("PriorityOrder", func(t *testing.T) {
// script field should take priority over scripts field
data := map[string]interface{}{
"script": `function Create1() { return null; }`,
"scripts": map[string]interface{}{
"tool1": `function tool1() { return "tool1"; }`,
},
"source": `function Create2() { return null; }`,
"path": "assistants/tests/mcpload",
}
hookScript, scripts, err := LoadScriptsFromData(data, "tests.mcpload")
require.NoError(t, err)
assert.NotNil(t, hookScript, "HookScript should be loaded")
require.NotNil(t, scripts, "Scripts should be loaded")
assert.Len(t, scripts, 1, "Should have 1 script from scripts field")
t.Logf("✓ Priority order works: script > scripts > source")
})
}
func TestGenerateScriptID(t *testing.T) {
tests := []struct {
name string
filePath string
srcDir string
expected string
}{
{
name: "Simple file",
filePath: "assistants/test/src/tools.ts",
srcDir: "assistants/test/src",
expected: "tools",
},
{
name: "Nested directory",
filePath: "assistants/test/src/foo/bar/test.ts",
srcDir: "assistants/test/src",
expected: "foo.bar.test",
},
{
name: "Single level nested",
filePath: "assistants/test/src/utils/helper.js",
srcDir: "assistants/test/src",
expected: "utils.helper",
},
{
name: "Deep nesting",
filePath: "assistants/test/src/a/b/c/d/file.ts",
srcDir: "assistants/test/src",
expected: "a.b.c.d.file",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := generateScriptID(tt.filePath, tt.srcDir)
assert.Equal(t, tt.expected, result, "Script ID should match expected value")
t.Logf("✓ %s: %s → %s", tt.name, tt.filePath, result)
})
}
}
// TestLoadScriptsThreadSafety tests concurrent script loading
// Note: This test is commented out due to path format differences
// Thread safety is ensured by the scriptsMutex in LoadScripts function
func TestExecuteWithAuthorized(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
t.Run("ExecuteWithAuthorizedInfo", func(t *testing.T) {
// Create a script that returns the authorized info from __yao_data
scriptSource := `
function GetAuth() {
if (typeof __yao_data !== 'undefined' && __yao_data.AUTHORIZED) {
return __yao_data.AUTHORIZED;
}
return null;
}
`
data := map[string]interface{}{
"scripts": map[string]interface{}{
"auth_test": scriptSource,
},
}
_, scripts, err := LoadScriptsFromData(data, "test.authorized")
require.NoError(t, err)
require.NotNil(t, scripts)
require.Contains(t, scripts, "auth_test")
script := scripts["auth_test"]
// Create authorized info
authorized := map[string]interface{}{
"user_id": "user123",
"team_id": "team456",
"scope": "read write",
"constraints": map[string]interface{}{
"team_only": true,
},
}
// Execute with authorized info
ctx := context.Background()
result, err := script.ExecuteWithAuthorized(ctx, "GetAuth", authorized)
require.NoError(t, err)
require.NotNil(t, result)
// Verify the authorized info was passed correctly
resultMap, ok := result.(map[string]interface{})
require.True(t, ok, "Result should be a map")
assert.Equal(t, "user123", resultMap["user_id"])
assert.Equal(t, "team456", resultMap["team_id"])
assert.Equal(t, "read write", resultMap["scope"])
constraints, ok := resultMap["constraints"].(map[string]interface{})
require.True(t, ok, "Constraints should be a map")
assert.Equal(t, true, constraints["team_only"])
t.Logf("✓ Authorized info passed correctly to script")
})
t.Run("ExecuteWithoutAuthorizedInfo", func(t *testing.T) {
// Create a script that checks for authorized info
scriptSource := `
function CheckAuth() {
if (typeof __yao_data !== 'undefined' && __yao_data.AUTHORIZED) {
return { hasAuth: true, data: __yao_data.AUTHORIZED };
}
return { hasAuth: false };
}
`
data := map[string]interface{}{
"scripts": map[string]interface{}{
"no_auth_test": scriptSource,
},
}
_, scripts, err := LoadScriptsFromData(data, "test.noauth")
require.NoError(t, err)
require.NotNil(t, scripts)
require.Contains(t, scripts, "no_auth_test")
script := scripts["no_auth_test"]
// Execute without authorized info
ctx := context.Background()
result, err := script.Execute(ctx, "CheckAuth")
require.NoError(t, err)
require.NotNil(t, result)
resultMap, ok := result.(map[string]interface{})
require.True(t, ok)
assert.Equal(t, false, resultMap["hasAuth"])
t.Logf("✓ Script executed correctly without authorized info")
})
t.Run("MakeScriptHandlerWithAuthorized", func(t *testing.T) {
// Create a script that returns authorized user_id
scriptSource := `
function GetUserID() {
if (typeof __yao_data !== 'undefined' && __yao_data.AUTHORIZED) {
return __yao_data.AUTHORIZED.user_id || null;
}
return null;
}
`
data := map[string]interface{}{
"scripts": map[string]interface{}{
"handler_test": scriptSource,
},
}
_, scripts, err := LoadScriptsFromData(data, "test.handler")
require.NoError(t, err)
require.NotNil(t, scripts)
require.Contains(t, scripts, "handler_test")
script := scripts["handler_test"]
// Create a process handler
handler := makeScriptHandler(script)
require.NotNil(t, handler)
// Create a mock process with authorized info
ctx := context.Background()
p := &process.Process{
Method: "GetUserID",
Args: []interface{}{},
Context: ctx,
Authorized: &process.AuthorizedInfo{
UserID: "user999",
TeamID: "team888",
Scope: "admin",
},
}
// Execute the handler
result := handler(p)
require.NotNil(t, result)
// Verify the result
assert.Equal(t, "user999", result)
t.Logf("✓ Process handler correctly passed authorized info")
})
}

File diff suppressed because it is too large Load diff

View file

@ -1,103 +0,0 @@
package assistant
import (
"github.com/yaoapp/gou/query/gou"
"github.com/yaoapp/yao/agent/context"
)
// BuildDBAuthWheres builds where clauses for DB search based on authorization
// This applies permission-based filtering to database queries
// Returns gou.Where clauses to filter records by authorization scope
func BuildDBAuthWheres(ctx *context.Context) []gou.Where {
if ctx == nil || ctx.Authorized == nil {
return nil
}
authInfo := ctx.Authorized
// No constraints, no filter needed
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
return nil
}
var wheres []gou.Where
// Team only - User can access:
// 1. Public records (public = true)
// 2. Records in their team where:
// - They created the record (__yao_created_by matches)
// - OR the record is shared with team (share = "team")
if authInfo.Constraints.TeamOnly && authInfo.TeamID != "" {
wheres = append(wheres, gou.Where{
Wheres: []gou.Where{
// Public records
{Condition: gou.Condition{
Field: &gou.Expression{Field: "public"},
Value: true,
OP: "=",
OR: true,
}},
// Team records
{
Wheres: []gou.Where{
{Condition: gou.Condition{
Field: &gou.Expression{Field: "__yao_team_id"},
Value: authInfo.TeamID,
OP: "=",
}},
{Wheres: []gou.Where{
{Condition: gou.Condition{
Field: &gou.Expression{Field: "__yao_created_by"},
Value: authInfo.UserID,
OP: "=",
}},
{Condition: gou.Condition{
Field: &gou.Expression{Field: "share"},
Value: "team",
OP: "=",
OR: true,
}},
}},
},
},
},
})
return wheres
}
// Owner only - User can access:
// 1. Public records (public = true)
// 2. Records they created where:
// - __yao_team_id is null (not team records)
// - __yao_created_by matches their user ID
if authInfo.Constraints.OwnerOnly && authInfo.UserID != "" {
wheres = append(wheres, gou.Where{
Wheres: []gou.Where{
// Public records
{Condition: gou.Condition{
Field: &gou.Expression{Field: "public"},
Value: true,
OP: "=",
OR: true,
}},
// Owner records
{
Wheres: []gou.Where{
{Condition: gou.Condition{
Field: &gou.Expression{Field: "__yao_team_id"},
OP: "null",
}},
{Condition: gou.Condition{
Field: &gou.Expression{Field: "__yao_created_by"},
Value: authInfo.UserID,
OP: "=",
}},
},
},
},
})
return wheres
}
return wheres
}

View file

@ -1,529 +0,0 @@
package assistant_test
import (
"context"
"fmt"
"testing"
"time"
"github.com/stretchr/testify/assert"
graphragtypes "github.com/yaoapp/gou/graphrag/types"
"github.com/yaoapp/yao/agent/assistant"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/search"
searchTypes "github.com/yaoapp/yao/agent/search/types"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/kb"
"github.com/yaoapp/yao/kb/api"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
// ========== Test Constants ==========
const (
// Test users and teams
TestUserA = "user_a"
TestUserB = "user_b"
TestTeam1 = "team_1"
TestTeam2 = "team_2"
)
// authTestCollections holds dynamically generated collection IDs for a test run
type authTestCollections struct {
Team1 string
Team2 string
Public string
}
// newAuthTestCollections creates unique collection IDs for a test run
func newAuthTestCollections() *authTestCollections {
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
return &authTestCollections{
Team1: fmt.Sprintf("auth_test_team1_%s", suffix),
Team2: fmt.Sprintf("auth_test_team2_%s", suffix),
Public: fmt.Sprintf("auth_test_public_%s", suffix),
}
}
// cleanup removes all test collections
func (c *authTestCollections) cleanup(ctx context.Context, t *testing.T) {
collections := []string{c.Team1, c.Team2, c.Public}
for _, id := range collections {
if result, err := kb.API.RemoveCollection(ctx, id); err == nil && result.Removed {
t.Logf(" Removed: %s", id)
}
}
}
// ========== KB Collection-Level Auth Filter Tests ==========
// Note: KB permission filtering works at the Collection level.
// The Collection metadata contains __yao_team_id, __yao_created_by, public, share fields.
// FilterKBCollectionsByAuth filters collections based on user authorization.
func TestKBCollectionAuthFilter(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t)
defer testutils.Clean(t)
if kb.API == nil {
t.Fatal("KB API not initialized")
}
ctx := context.Background()
cols := newAuthTestCollections()
defer cols.cleanup(ctx, t)
// Create test collections
t.Log("Creating test collections...")
createAuthCollection(ctx, t, cols.Team1, TestUserA, TestTeam1, false, "team")
createAuthCollection(ctx, t, cols.Team2, TestUserB, TestTeam2, false, "team")
createAuthCollection(ctx, t, cols.Public, TestUserA, TestTeam1, true, "")
t.Run("TeamMemberCanAccessTeamCollection", func(t *testing.T) {
// UserA from Team1 should access Team1 collection
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
collections := []string{cols.Team1, cols.Team2}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
assert.Contains(t, allowed, cols.Team1, "Team1 member should access Team1 collection")
t.Logf(" Allowed collections: %v", allowed)
})
t.Run("TeamMemberCannotAccessOtherTeamCollection", func(t *testing.T) {
// UserA from Team1 should NOT access Team2 collection
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
collections := []string{cols.Team2}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
assert.NotContains(t, allowed, cols.Team2, "Team1 member should NOT access Team2 collection")
t.Logf(" Allowed collections: %v (expected empty)", allowed)
})
t.Run("OwnerCanAccessOwnCollection", func(t *testing.T) {
// UserA with OwnerOnly should access collections they created
authCtx := createAuthContext(TestUserA, "", false, true)
collections := []string{cols.Team1, cols.Team2}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
assert.Contains(t, allowed, cols.Team1, "Owner should access own collection")
assert.NotContains(t, allowed, cols.Team2, "Owner should NOT access other's collection")
t.Logf(" Allowed collections: %v", allowed)
})
t.Run("PublicCollectionAccessibleToAll", func(t *testing.T) {
// Note: The 'public' field in Metadata is not automatically saved to the database
// by the current KB API. This test documents the expected behavior.
// When public=true is properly set in DB, this should pass.
// First, check the collection metadata
collection, err := kb.API.GetCollection(ctx, cols.Public)
assert.NoError(t, err)
// Check if public is set correctly
publicVal := collection["public"]
t.Logf(" Public collection public field: %v (type: %T)", publicVal, publicVal)
// If public is not set (0 or false), the test documents current behavior
// The collection should be accessible via owner check since UserA created it
authCtx := createAuthContext(TestUserA, TestTeam1, false, true) // Owner check
collections := []string{cols.Public}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
assert.Contains(t, allowed, cols.Public, "Owner should access their collection")
t.Logf(" Allowed collections (owner check): %v", allowed)
})
t.Run("NoConstraintsMeansFullAccess", func(t *testing.T) {
// User with no constraints should access all collections
authCtx := createAuthContext(TestUserA, TestTeam1, false, false)
collections := []string{cols.Team1, cols.Team2, cols.Public}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
assert.Len(t, allowed, 3, "No constraints should allow all collections")
t.Logf(" Allowed collections: %v", allowed)
})
t.Run("NilContextMeansFullAccess", func(t *testing.T) {
collections := []string{cols.Team1, cols.Team2}
allowed := assistant.FilterKBCollectionsByAuth(nil, collections)
assert.Len(t, allowed, 2, "Nil context should allow all collections")
t.Logf(" Allowed collections: %v", allowed)
})
}
// ========== DB Auth Wheres Tests ==========
func TestDBAuthWheresFilter(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
// Note: This test doesn't need KB, just tests the BuildDBAuthWheres function
t.Run("TeamOnlyGeneratesCorrectWheres", func(t *testing.T) {
ctx := createAuthContext(TestUserA, TestTeam1, true, false)
wheres := assistant.BuildDBAuthWheres(ctx)
assert.NotNil(t, wheres)
assert.Len(t, wheres, 1)
// Verify structure: should have 2 top-level conditions (public OR team filter)
where := wheres[0]
assert.Len(t, where.Wheres, 2, "Should have 2 conditions: public OR team")
// First condition: public = true (OR)
publicCond := where.Wheres[0]
assert.NotNil(t, publicCond.Condition.Field)
assert.Equal(t, "public", publicCond.Condition.Field.Field)
assert.Equal(t, true, publicCond.Condition.Value)
assert.True(t, publicCond.Condition.OR)
// Second condition: team filter with nested conditions
teamCond := where.Wheres[1]
assert.Len(t, teamCond.Wheres, 2, "Team filter should have team_id and (created_by OR share)")
// Team ID check
teamIDCond := teamCond.Wheres[0]
assert.Equal(t, "__yao_team_id", teamIDCond.Condition.Field.Field)
assert.Equal(t, TestTeam1, teamIDCond.Condition.Value)
// Created by OR share = team
ownerOrShareCond := teamCond.Wheres[1]
assert.Len(t, ownerOrShareCond.Wheres, 2)
assert.Equal(t, "__yao_created_by", ownerOrShareCond.Wheres[0].Condition.Field.Field)
assert.Equal(t, TestUserA, ownerOrShareCond.Wheres[0].Condition.Value)
assert.Equal(t, "share", ownerOrShareCond.Wheres[1].Condition.Field.Field)
assert.Equal(t, "team", ownerOrShareCond.Wheres[1].Condition.Value)
assert.True(t, ownerOrShareCond.Wheres[1].Condition.OR)
t.Logf(" TeamOnly: Verified team_id=%s, created_by=%s", TestTeam1, TestUserA)
})
t.Run("OwnerOnlyGeneratesCorrectWheres", func(t *testing.T) {
ctx := createAuthContext(TestUserA, "", false, true)
wheres := assistant.BuildDBAuthWheres(ctx)
assert.NotNil(t, wheres)
assert.Len(t, wheres, 1)
// Verify structure: should have 2 top-level conditions (public OR owner filter)
where := wheres[0]
assert.Len(t, where.Wheres, 2, "Should have 2 conditions: public OR owner")
// First condition: public = true (OR)
publicCond := where.Wheres[0]
assert.NotNil(t, publicCond.Condition.Field)
assert.Equal(t, "public", publicCond.Condition.Field.Field)
assert.Equal(t, true, publicCond.Condition.Value)
assert.True(t, publicCond.Condition.OR)
// Second condition: owner filter with nested conditions
ownerCond := where.Wheres[1]
assert.Len(t, ownerCond.Wheres, 2, "Owner filter should have team_id IS NULL and created_by")
// Team ID is null check
teamNullCond := ownerCond.Wheres[0]
assert.Equal(t, "__yao_team_id", teamNullCond.Condition.Field.Field)
assert.Equal(t, "null", teamNullCond.Condition.OP)
// Created by check
createdByCond := ownerCond.Wheres[1]
assert.Equal(t, "__yao_created_by", createdByCond.Condition.Field.Field)
assert.Equal(t, TestUserA, createdByCond.Condition.Value)
t.Logf(" OwnerOnly: Verified created_by=%s, team_id IS NULL", TestUserA)
})
t.Run("NoConstraintsReturnsNil", func(t *testing.T) {
ctx := createAuthContext(TestUserA, TestTeam1, false, false)
wheres := assistant.BuildDBAuthWheres(ctx)
assert.Nil(t, wheres, "No constraints should return nil")
t.Log(" No constraints: nil wheres (no filter)")
})
t.Run("EmptyTeamIDReturnsNil", func(t *testing.T) {
ctx := createAuthContext(TestUserA, "", true, false)
wheres := assistant.BuildDBAuthWheres(ctx)
assert.Nil(t, wheres, "Empty TeamID with TeamOnly should return nil")
t.Log(" Empty TeamID with TeamOnly: nil wheres")
})
t.Run("EmptyUserIDReturnsNil", func(t *testing.T) {
ctx := createAuthContext("", TestTeam1, false, true)
wheres := assistant.BuildDBAuthWheres(ctx)
assert.Nil(t, wheres, "Empty UserID with OwnerOnly should return nil")
t.Log(" Empty UserID with OwnerOnly: nil wheres")
})
t.Run("NilContextReturnsNil", func(t *testing.T) {
wheres := assistant.BuildDBAuthWheres(nil)
assert.Nil(t, wheres, "Nil context should return nil")
t.Log(" Nil context: nil wheres")
})
t.Run("NilAuthorizedReturnsNil", func(t *testing.T) {
ctx := agentContext.New(context.Background(), nil, "test-chat")
wheres := assistant.BuildDBAuthWheres(ctx)
assert.Nil(t, wheres, "Nil Authorized should return nil")
t.Log(" Nil Authorized: nil wheres")
})
}
// ========== KB Search Integration Tests ==========
func TestKBSearchIntegration(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
testutils.Prepare(t)
defer testutils.Clean(t)
if kb.API == nil {
t.Fatal("KB API not initialized")
}
ctx := context.Background()
cols := newAuthTestCollections()
defer cols.cleanup(ctx, t)
// Create test collections with documents
t.Log("Creating test collections with documents...")
createAuthCollection(ctx, t, cols.Team1, TestUserA, TestTeam1, false, "team")
addAuthDocument(ctx, t, cols.Team1, "Team1 Doc1", "Team1 private document about quantum physics and relativity theory.")
addAuthDocument(ctx, t, cols.Team1, "Team1 Doc2", "Team1 shared document about machine learning and neural networks.")
createAuthCollection(ctx, t, cols.Team2, TestUserB, TestTeam2, false, "team")
addAuthDocument(ctx, t, cols.Team2, "Team2 Doc1", "Team2 private document about deep learning algorithms.")
addAuthDocument(ctx, t, cols.Team2, "Team2 Doc2", "Team2 shared document about computer vision techniques.")
createAuthCollection(ctx, t, cols.Public, TestUserA, TestTeam1, true, "")
addAuthDocument(ctx, t, cols.Public, "Public Doc1", "Public document about artificial intelligence and robotics.")
addAuthDocument(ctx, t, cols.Public, "Public Doc2", "Public document about natural language processing.")
// Wait for indexing
t.Log("Waiting for indexing...")
time.Sleep(2 * time.Second)
t.Run("TeamMemberSearchOnlyFindsTeamData", func(t *testing.T) {
// UserA from Team1 searches - should ONLY find Team1 data
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
// Filter collections first
allCollections := []string{cols.Team1, cols.Team2}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
// Should only allow Team1
assert.Contains(t, allowed, cols.Team1)
assert.NotContains(t, allowed, cols.Team2)
assert.Len(t, allowed, 1, "Should only have 1 allowed collection")
// Search on allowed collections
result := executeKBSearchOnCollections(t, allowed, "quantum physics deep learning")
assert.Greater(t, len(result.Items), 0, "Should find Team1 documents")
// Verify ALL results are from Team1 collection only
for _, item := range result.Items {
assert.Equal(t, cols.Team1, item.Collection,
"All results should be from Team1 collection, got: %s", item.Collection)
}
t.Logf(" ✓ Team1 member found %d items, all from Team1 collection", len(result.Items))
})
t.Run("TeamMemberCannotAccessOtherTeamData", func(t *testing.T) {
// UserA from Team1 tries to access Team2 - should be blocked
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
// Try to filter Team2 collection
collections := []string{cols.Team2}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
// Should be empty - no access
assert.Empty(t, allowed, "Team1 member should NOT have access to Team2 collection")
t.Log(" ✓ Team1 member correctly blocked from Team2 collection")
})
t.Run("OwnerSearchOnlyFindsOwnData", func(t *testing.T) {
// UserA with OwnerOnly - should only find collections they created
authCtx := createAuthContext(TestUserA, "", false, true)
// Filter all collections
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
// UserA created Team1 and Public, not Team2
assert.Contains(t, allowed, cols.Team1, "Owner should access Team1 (created by UserA)")
assert.Contains(t, allowed, cols.Public, "Owner should access Public (created by UserA)")
assert.NotContains(t, allowed, cols.Team2, "Owner should NOT access Team2 (created by UserB)")
// Search and verify results
result := executeKBSearchOnCollections(t, allowed, "quantum artificial intelligence")
assert.Greater(t, len(result.Items), 0, "Should find owner's documents")
// Verify NO results from Team2
for _, item := range result.Items {
assert.NotEqual(t, cols.Team2, item.Collection,
"Should NOT have results from Team2, got: %s", item.Collection)
}
t.Logf(" ✓ Owner found %d items, none from Team2", len(result.Items))
})
t.Run("NoConstraintsSearchFindsAllData", func(t *testing.T) {
// User with no constraints - should find all data
authCtx := createAuthContext(TestUserA, TestTeam1, false, false)
// Filter all collections
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
// Should have access to all
assert.Len(t, allowed, 3, "No constraints should allow all collections")
// Search and verify results from multiple collections
result := executeKBSearchOnCollections(t, allowed, "quantum deep learning artificial")
// Should find results from multiple collections
collectionsFound := make(map[string]bool)
for _, item := range result.Items {
collectionsFound[item.Collection] = true
}
assert.Greater(t, len(collectionsFound), 1, "Should find results from multiple collections")
t.Logf(" ✓ No constraints: found %d items from %d collections", len(result.Items), len(collectionsFound))
})
t.Run("SearchResultsMatchCollectionFilter", func(t *testing.T) {
// Verify that search results ONLY come from allowed collections
authCtx := createAuthContext(TestUserB, TestTeam2, true, false)
// UserB from Team2 - should only access Team2
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
assert.Contains(t, allowed, cols.Team2, "Team2 member should access Team2")
assert.NotContains(t, allowed, cols.Team1, "Team2 member should NOT access Team1")
// Search
result := executeKBSearchOnCollections(t, allowed, "deep learning computer vision")
// Verify results
if len(result.Items) > 0 {
for _, item := range result.Items {
// Results should only be from allowed collections
assert.Contains(t, allowed, item.Collection,
"Result from %s should be in allowed list %v", item.Collection, allowed)
}
t.Logf(" ✓ Team2 member found %d items, all from allowed collections", len(result.Items))
} else {
t.Log(" ✓ Team2 member found 0 items (collection may be empty)")
}
})
}
// ========== Helper Functions ==========
func createAuthContext(userID, teamID string, teamOnly, ownerOnly bool) *agentContext.Context {
authorized := &oauthtypes.AuthorizedInfo{
UserID: userID,
TeamID: teamID,
Constraints: oauthtypes.DataConstraints{
TeamOnly: teamOnly,
OwnerOnly: ownerOnly,
},
}
return agentContext.New(context.Background(), authorized, "test-chat")
}
func createAuthCollection(ctx context.Context, t *testing.T, id, userID, teamID string, public bool, share string) {
params := &api.CreateCollectionParams{
ID: id,
Metadata: map[string]interface{}{
"name": id,
"public": public,
"share": share,
},
EmbeddingProviderID: "__yao.openai",
EmbeddingOptionID: "text-embedding-3-small",
Locale: "en",
Config: &graphragtypes.CreateCollectionOptions{
Distance: "cosine",
IndexType: "hnsw",
},
AuthScope: map[string]interface{}{
"__yao_created_by": userID,
"__yao_team_id": teamID,
},
}
_, err := kb.API.CreateCollection(ctx, params)
if err != nil {
t.Fatalf("Failed to create collection %s: %v", id, err)
}
t.Logf(" ✓ Created: %s", id)
}
func addAuthDocument(ctx context.Context, t *testing.T, collectionID, title, content string) {
params := &api.AddTextParams{
CollectionID: collectionID,
Text: content,
DocID: fmt.Sprintf("%s__%s", collectionID, sanitizeForID(title)),
Metadata: map[string]interface{}{
"title": title,
},
Chunking: &api.ProviderConfigParams{
ProviderID: "__yao.structured",
OptionID: "standard",
},
Embedding: &api.ProviderConfigParams{
ProviderID: "__yao.openai",
OptionID: "text-embedding-3-small",
},
}
_, err := kb.API.AddText(ctx, params)
if err != nil {
t.Logf(" Warning: Failed to add document '%s': %v", title, err)
return
}
t.Logf(" ✓ Added: %s", title)
}
func sanitizeForID(s string) string {
result := ""
for _, c := range s {
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') {
result += string(c)
} else if c == ' ' {
result += "_"
}
}
return result
}
func executeKBSearchOnCollections(t *testing.T, collections []string, query string) *searchTypes.Result {
if len(collections) == 0 {
return &searchTypes.Result{Items: []*searchTypes.ResultItem{}}
}
cfg := &searchTypes.Config{
KB: &searchTypes.KBConfig{
Collections: collections,
Threshold: 0.3,
},
}
searcher := search.New(cfg, nil)
req := &searchTypes.Request{
Type: searchTypes.SearchTypeKB,
Query: query,
Collections: collections,
Threshold: 0.3,
Limit: 20,
Source: searchTypes.SourceAuto,
}
result, err := searcher.Search(nil, req)
if err != nil {
t.Fatalf("Search failed: %v", err)
}
return result
}

View file

@ -1,123 +0,0 @@
package assistant
import (
"context"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/kb"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
)
// FilterKBCollectionsByAuth filters collections based on user authorization.
// Returns only collections that the user has permission to access.
// Permission is determined by Collection's metadata (public, share, __yao_team_id, __yao_created_by).
func FilterKBCollectionsByAuth(ctx *agentContext.Context, collections []string) []string {
if ctx == nil || ctx.Authorized == nil {
return collections // No auth context, return all
}
authInfo := ctx.Authorized
// No constraints, return all collections
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
return collections
}
// Check KB API
if kb.API == nil {
return collections // KB not initialized, return all
}
var allowed []string
bgCtx := context.Background()
for _, collectionID := range collections {
// Get collection metadata
collection, err := kb.API.GetCollection(bgCtx, collectionID)
if err != nil {
continue // Skip if can't get collection
}
if hasCollectionAccess(authInfo, collection) {
allowed = append(allowed, collectionID)
}
}
return allowed
}
// hasCollectionAccess checks if user has access to a collection based on its metadata.
func hasCollectionAccess(authInfo *oauthtypes.AuthorizedInfo, collection map[string]interface{}) bool {
if authInfo == nil {
return true
}
// No constraints, allow access
if !authInfo.Constraints.TeamOnly && !authInfo.Constraints.OwnerOnly {
return true
}
// Check public access (handle different types: bool, int, float64)
if isPublicValue(collection["public"]) {
return true
}
// Get metadata for permission fields
metadata, _ := collection["metadata"].(map[string]interface{})
if metadata == nil {
metadata = collection
}
// Team only check
if authInfo.Constraints.TeamOnly && authInfo.TeamID != "" {
teamID, _ := metadata["__yao_team_id"].(string)
if teamID == "" {
teamID, _ = collection["__yao_team_id"].(string)
}
if teamID == authInfo.TeamID {
createdBy, _ := metadata["__yao_created_by"].(string)
if createdBy == "" {
createdBy, _ = collection["__yao_created_by"].(string)
}
share, _ := metadata["share"].(string)
if share == "" {
share, _ = collection["share"].(string)
}
if createdBy == authInfo.UserID || share == "team" {
return true
}
}
}
// Owner only check
if authInfo.Constraints.OwnerOnly && authInfo.UserID != "" {
createdBy, _ := metadata["__yao_created_by"].(string)
if createdBy == "" {
createdBy, _ = collection["__yao_created_by"].(string)
}
if createdBy == authInfo.UserID {
return true
}
}
return false
}
// isPublicValue checks if a value represents "public" access
func isPublicValue(v interface{}) bool {
switch val := v.(type) {
case bool:
return val
case int:
return val == 1
case int64:
return val == 1
case float64:
return val == 1
case string:
return val == "true" || val == "1"
}
return false
}

View file

@ -1,83 +0,0 @@
package assistant_test
import (
stdContext "context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newSearchAutoDisabledTestContext creates a test context
func newSearchAutoDisabledTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Client = context.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
return ctx
}
func TestSearchAutoDisabled(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/search-auto-disabled")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldHaveSearchConfig", func(t *testing.T) {
// Search config is set but uses.search is disabled
assert.NotNil(t, ast.Search, "search config should be set")
assert.NotNil(t, ast.Search.Web, "web search config should be set")
})
t.Run("ShouldHaveDisabledUses", func(t *testing.T) {
assert.NotNil(t, ast.Uses, "uses config should be set")
assert.Equal(t, "disabled", ast.Uses.Search, "uses.search should be disabled")
})
t.Run("StreamShouldNotExecuteSearch", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-disabled")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newSearchAutoDisabledTestContext("test-search-auto-disabled", "tests.search-auto-disabled")
// Create messages
messages := []context.Message{
{
Role: "user",
Content: "Hello, how are you?",
},
}
// Execute stream - should NOT trigger search because uses.search is "disabled"
response, err := agent.Stream(ctx, messages)
require.NoError(t, err)
require.NotNil(t, response)
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream executed without search (disabled)")
})
}

View file

@ -1,130 +0,0 @@
package assistant_test
import (
stdContext "context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newSearchAutoFullTestContext creates a test context
func newSearchAutoFullTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Client = context.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
return ctx
}
func TestSearchAutoFull(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/search-auto-full")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldHaveWebSearchConfig", func(t *testing.T) {
assert.NotNil(t, ast.Search, "search config should be set")
assert.NotNil(t, ast.Search.Web, "web search config should be set")
assert.Equal(t, "tavily", ast.Search.Web.Provider)
assert.Equal(t, 3, ast.Search.Web.MaxResults)
})
// KB/DB search temporarily disabled
t.Run("ShouldHaveKBSearchConfig", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.Search.KB, "kb search config should be set")
assert.Equal(t, 0.7, ast.Search.KB.Threshold)
assert.False(t, ast.Search.KB.Graph)
})
t.Run("ShouldHaveDBSearchConfig", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.Search.DB, "db search config should be set")
assert.Equal(t, 10, ast.Search.DB.MaxResults)
})
t.Run("ShouldHaveKBCollections", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.KB, "kb config should be set")
assert.Contains(t, ast.KB.Collections, "test-collection")
})
t.Run("ShouldHaveDBModels", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
assert.NotNil(t, ast.DB, "db config should be set")
assert.Contains(t, ast.DB.Models, "user")
assert.Contains(t, ast.DB.Models, "article")
})
t.Run("ShouldHaveCitationConfig", func(t *testing.T) {
assert.NotNil(t, ast.Search.Citation, "citation config should be set")
assert.Equal(t, "xml", ast.Search.Citation.Format)
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
})
t.Run("ShouldHaveUsesConfig", func(t *testing.T) {
assert.NotNil(t, ast.Uses, "uses config should be set")
assert.Equal(t, "builtin", ast.Uses.Search)
assert.Equal(t, "builtin", ast.Uses.Web)
})
t.Run("StreamShouldExecuteMultipleSearchTypes", func(t *testing.T) {
t.Skip("KB/DB search temporarily disabled")
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-full")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newSearchAutoFullTestContext("test-search-auto-full", "tests.search-auto-full")
// Create messages with a search query
messages := []context.Message{
{
Role: "user",
Content: "Find information about machine learning",
},
}
// Execute stream - should trigger Web + KB + DB searches
response, err := agent.Stream(ctx, messages)
// Assert no error (if API key is configured)
if err != nil {
// If error contains "API key", it's expected in CI without keys
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
// Other errors should fail
require.NoError(t, err)
}
require.NotNil(t, response)
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream executed with full search config (Web + KB + DB)")
})
}

View file

@ -1,107 +0,0 @@
package assistant_test
import (
stdContext "context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newSearchAutoHookDisableTestContext creates a test context
func newSearchAutoHookDisableTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Client = context.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
return ctx
}
func TestSearchAutoHookDisable(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/search-auto-hook-disable")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldHaveSearchConfigEnabled", func(t *testing.T) {
// Search config is enabled in package.yao
assert.NotNil(t, ast.Search, "search config should be set")
assert.NotNil(t, ast.Uses, "uses config should be set")
assert.Equal(t, "builtin", ast.Uses.Search, "uses.search should be builtin in config")
})
t.Run("ShouldHaveHookScript", func(t *testing.T) {
// Hook script should be loaded
assert.NotNil(t, ast.HookScript, "hook script should be loaded")
})
t.Run("HookShouldDisableSearch", func(t *testing.T) {
// Create context
ctx := newSearchAutoHookDisableTestContext("test-chat-id", "tests.search-auto-hook-disable")
// Create messages
messages := []context.Message{
{
Role: "user",
Content: "Test message",
},
}
// Call Create hook directly
opts := &context.Options{}
response, _, err := ast.HookScript.Create(ctx, messages, opts)
require.NoError(t, err)
require.NotNil(t, response)
// Verify hook returns uses.search = "disabled"
assert.NotNil(t, response.Uses, "hook should return uses")
assert.Equal(t, "disabled", response.Uses.Search, "hook should disable search")
})
t.Run("StreamShouldRespectHookDisable", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-hook-disable")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newSearchAutoHookDisableTestContext("test-search-hook-disable", "tests.search-auto-hook-disable")
// Create messages
messages := []context.Message{
{
Role: "user",
Content: "What is AI?",
},
}
// Execute stream - hook will disable search
response, err := agent.Stream(ctx, messages)
require.NoError(t, err)
require.NotNil(t, response)
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream executed with hook disabling search")
})
}

View file

@ -1,183 +0,0 @@
package assistant_test
import (
stdContext "context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newKeywordTestContext creates a test context for keyword extraction tests
func newKeywordTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Client = context.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
return ctx
}
func TestSearchAutoKeyword(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/search-auto-keyword")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldHaveKeywordConfig", func(t *testing.T) {
assert.NotNil(t, ast.Search, "search config should be set")
assert.NotNil(t, ast.Search.Keyword, "keyword config should be set")
assert.Equal(t, 5, ast.Search.Keyword.MaxKeywords)
assert.Equal(t, "auto", ast.Search.Keyword.Language)
})
t.Run("ShouldHaveKeywordInUses", func(t *testing.T) {
assert.NotNil(t, ast.Uses, "uses config should be set")
assert.Equal(t, "builtin", ast.Uses.Keyword)
})
t.Run("StreamWithKeywordExtraction", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-keyword")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newKeywordTestContext("test-search-keyword", "tests.search-auto-keyword")
// Create messages with a verbose query that should benefit from keyword extraction
messages := []context.Message{
{
Role: "user",
Content: "I want to find the best wireless headphones under 100 dollars for programming and music",
},
}
// Execute stream without Skip.Keyword (keyword extraction should happen)
response, err := agent.Stream(ctx, messages)
// Assert no error (if API key is configured)
if err != nil {
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
require.NoError(t, err)
}
require.NotNil(t, response)
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream with keyword extraction executed successfully")
})
t.Run("StreamWithSkipKeyword", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-keyword")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newKeywordTestContext("test-search-skip-keyword", "tests.search-auto-keyword")
// Create messages
messages := []context.Message{
{
Role: "user",
Content: "I want to find the best wireless headphones under 100 dollars",
},
}
// Execute stream with Skip.Keyword = true (keyword extraction should be skipped)
opts := &context.Options{
Skip: &context.Skip{
Keyword: true,
},
}
response, err := agent.Stream(ctx, messages, opts)
// Assert no error (if API key is configured)
if err != nil {
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
require.NoError(t, err)
}
require.NotNil(t, response)
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream with Skip.Keyword executed successfully")
})
}
func TestSearchAutoKeywordNotConfigured(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
// Use the search-auto-web assistant which does NOT have uses.keyword configured
ast, err := assistant.LoadPath("/assistants/tests/search-auto-web")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldNotHaveKeywordInUses", func(t *testing.T) {
// uses.keyword should be empty (not configured)
if ast.Uses != nil {
assert.Empty(t, ast.Uses.Keyword, "uses.keyword should be empty")
}
})
t.Run("StreamShouldSkipKeywordExtraction", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-web")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newKeywordTestContext("test-no-keyword", "tests.search-auto-web")
// Create messages
messages := []context.Message{
{
Role: "user",
Content: "What is the latest news about AI?",
},
}
// Execute stream - keyword extraction should NOT happen because uses.keyword is not set
response, err := agent.Stream(ctx, messages)
// Assert no error (if API key is configured)
if err != nil {
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
require.NoError(t, err)
}
require.NotNil(t, response)
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream without keyword config executed successfully")
})
}

View file

@ -1,102 +0,0 @@
package assistant_test
import (
stdContext "context"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// newSearchAutoTestContext creates a test context for search auto tests
func newSearchAutoTestContext(chatID, assistantID string) *context.Context {
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
ctx := context.New(stdContext.Background(), authorized, chatID)
ctx.ID = chatID
ctx.AssistantID = assistantID
ctx.Locale = "en-us"
ctx.Client = context.Client{
Type: "web",
IP: "127.0.0.1",
}
ctx.Referer = context.RefererAPI
ctx.Accept = context.AcceptWebCUI
ctx.IDGenerator = message.NewIDGenerator()
ctx.Metadata = make(map[string]interface{})
return ctx
}
func TestSearchAutoWeb(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.LoadPath("/assistants/tests/search-auto-web")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("ShouldHaveSearchConfig", func(t *testing.T) {
assert.NotNil(t, ast.Search, "search config should be set")
assert.NotNil(t, ast.Search.Web, "web search config should be set")
assert.Equal(t, "tavily", ast.Search.Web.Provider)
assert.Equal(t, 3, ast.Search.Web.MaxResults)
})
t.Run("ShouldHaveUsesConfig", func(t *testing.T) {
assert.NotNil(t, ast.Uses, "uses config should be set")
assert.Equal(t, "builtin", ast.Uses.Search)
assert.Equal(t, "builtin", ast.Uses.Web)
})
t.Run("ShouldHaveCitationConfig", func(t *testing.T) {
assert.NotNil(t, ast.Search.Citation, "citation config should be set")
assert.Equal(t, "xml", ast.Search.Citation.Format)
assert.True(t, ast.Search.Citation.AutoInjectPrompt)
})
t.Run("StreamShouldExecuteAutoSearch", func(t *testing.T) {
// Get agent via assistant.Get (required for Stream)
agent, err := assistant.Get("tests.search-auto-web")
require.NoError(t, err)
require.NotNil(t, agent)
// Create context
ctx := newSearchAutoTestContext("test-search-auto-web", "tests.search-auto-web")
// Create messages with a search query
messages := []context.Message{
{
Role: "user",
Content: "What is the latest news about artificial intelligence?",
},
}
// Execute stream
response, err := agent.Stream(ctx, messages)
// Assert no error (if API key is configured)
if err != nil {
// If error contains "API key", it's expected in CI without keys
if strings.Contains(err.Error(), "API key") || strings.Contains(err.Error(), "api_key") {
t.Logf("Expected error without API key: %v", err)
return
}
// Other errors should fail
require.NoError(t, err)
}
require.NotNil(t, response)
assert.NotNil(t, response.Completion, "should have completion")
t.Logf("✓ Stream executed successfully with auto search")
})
}

View file

@ -1,39 +0,0 @@
package assistant
import (
"fmt"
"strings"
"time"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/agent/assistant/hook"
)
// loadSource loads hook script from source code string
// The source field stores TypeScript code directly (but without imports)
// Priority: script field > source field (if script exists, source is ignored)
// Note: Uses MakeScriptInMemory which supports TypeScript syntax without file resolution.
func loadSource(source string, assistantID string) (*hook.Script, error) {
if source == "" {
return nil, nil
}
// Use virtual .ts path for TypeScript support
// MakeScriptInMemory handles TypeScript transform without file system access
virtualFile := fmt.Sprintf("assistants/%s/source.ts", strings.ReplaceAll(assistantID, ".", "/"))
script, err := v8.MakeScriptInMemory([]byte(source), virtualFile, 5*time.Second, true)
if err != nil {
return nil, fmt.Errorf("failed to compile source script: %w", err)
}
return &hook.Script{Script: script}, nil
}
// TODO: Future enhancement - support multiple files merged with special comment delimiter
// Format: // file: index.ts
// This would allow splitting large scripts into multiple logical files while storing as single source
// func loadSourceMultiFile(source string, assistantID string) (*hook.Script, error) {
// // Parse source by "// file: xxx.ts" delimiter
// // Merge and compile
// }

View file

@ -1,174 +0,0 @@
package assistant
import (
"fmt"
"github.com/yaoapp/gou/connector/openai"
"github.com/yaoapp/kun/log"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
"github.com/yaoapp/yao/trace/types"
)
// initAgentTraceNode creates and returns the agent trace node
func (ast *Assistant) initAgentTraceNode(ctx *context.Context, inputMessages []context.Message) types.Node {
trace, _ := ctx.Trace()
if trace == nil {
return nil
}
agentNode, _ := trace.Add(inputMessages, types.TraceNodeOption{
Label: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.label"), // "Assistant {{name}}"
Type: "agent",
Icon: "assistant",
Description: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.description"), // "Assistant {{name}} is processing the request"
})
return agentNode
}
// traceAgentHistory logs the chat history to the agent trace node
func (ast *Assistant) traceAgentHistory(ctx *context.Context, agentNode types.Node, fullMessages []context.Message) {
if agentNode == nil {
return
}
agentNode.Info(
i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.stream.history"), // "Get Chat History"
map[string]any{"messages": fullMessages},
)
}
// traceCreateHook logs the create hook response to the agent trace node
func (ast *Assistant) traceCreateHook(agentNode types.Node, createResponse *context.HookCreateResponse) {
if agentNode == nil {
return
}
agentNode.Debug("Call Create Hook", map[string]any{"response": createResponse})
}
// traceConnectorCapabilities logs the connector capabilities to the agent trace node
func (ast *Assistant) traceConnectorCapabilities(agentNode types.Node, capabilities *openai.Capabilities) {
if agentNode == nil {
return
}
agentNode.Debug("Get Connector Capabilities", map[string]any{"capabilities": capabilities})
}
// traceLLMRequest adds a LLM trace node to the trace
func (ast *Assistant) traceLLMRequest(ctx *context.Context, connID string, completionMessages []context.Message, completionOptions *context.CompletionOptions) {
trace, _ := ctx.Trace()
if trace == nil {
return
}
trace.Add(
map[string]any{"messages": completionMessages, "options": completionOptions},
types.TraceNodeOption{
Label: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.label"), connID), // "LLM %s"
Type: "llm",
Icon: "psychology",
Description: fmt.Sprintf(i18n.Tr(ast.ID, ctx.Locale, "llm.openai.stream.description"), connID), // "LLM %s is processing the request"
},
)
}
// traceLLMComplete marks the LLM request as complete in the trace
func (ast *Assistant) traceLLMComplete(ctx *context.Context, completionResponse *context.CompletionResponse) {
trace, _ := ctx.Trace()
if trace == nil {
return
}
trace.Complete(completionResponse)
}
// traceLLMFail marks the LLM request as failed in the trace
func (ast *Assistant) traceLLMFail(ctx *context.Context, err error) {
trace, _ := ctx.Trace()
if trace == nil {
return
}
trace.Fail(err)
}
// traceAgentCompletion creates a completion node to report the final output
func (ast *Assistant) traceAgentCompletion(ctx *context.Context, createResponse *context.HookCreateResponse, nextResponse *context.NextHookResponse, completionResponse *context.CompletionResponse, finalResponse interface{}) {
trace, _ := ctx.Trace()
if trace == nil {
return
}
// Prepare the input data (the raw responses before processing)
input := map[string]interface{}{
"create": createResponse,
"next": nextResponse,
"completion": completionResponse,
}
// Create a dedicated completion node
completionNode, err := trace.Add(
input,
types.TraceNodeOption{
Label: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.completion.label"), // "Agent Completion"
Type: "agent_completion",
Icon: "check_circle",
Description: i18n.Tr(ast.ID, ctx.Locale, "assistant.agent.completion.description"), // "Final output from assistant"
},
)
if err != nil {
log.Trace("[TRACE] Failed to create completion node: %v", err)
return
}
// Immediately mark it as complete with the final response
if completionNode != nil {
completionNode.Complete(finalResponse)
}
}
// traceAgentOutput sets the output of the agent trace node
// Deprecated: Use traceAgentCompletion instead for better trace structure
func (ast *Assistant) traceAgentOutput(agentNode types.Node, createResponse *context.HookCreateResponse, nextResponse interface{}, completionResponse *context.CompletionResponse) {
if agentNode == nil {
return
}
output := context.Response{
Create: createResponse,
Next: nextResponse,
Completion: completionResponse,
}
agentNode.Complete(output)
}
// traceAgentFail marks the agent trace node as failed
func (ast *Assistant) traceAgentFail(agentNode types.Node, err error) {
if agentNode == nil {
return
}
agentNode.Fail(err)
}
// traceLLMRetryRequest adds a LLM retry trace node to the trace
func (ast *Assistant) traceLLMRetryRequest(ctx *context.Context, connID string, completionMessages []context.Message, completionOptions *context.CompletionOptions) {
trace, _ := ctx.Trace()
if trace == nil {
return
}
trace.Add(
map[string]any{"messages": completionMessages, "options": completionOptions},
types.TraceNodeOption{
Label: fmt.Sprintf("LLM %s (Tool Retry)", connID),
Type: "llm_retry",
Icon: "refresh",
Description: fmt.Sprintf("LLM %s is retrying with tool call error feedback", connID),
},
)
}

View file

@ -1,149 +0,0 @@
package assistant
import (
jsoniter "github.com/json-iterator/go"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/agent/assistant/hook"
chatctx "github.com/yaoapp/yao/agent/context"
outputMessage "github.com/yaoapp/yao/agent/output/message"
store "github.com/yaoapp/yao/agent/store/types"
)
const (
// HookErrorMethodNotFound is the error message for method not found
HookErrorMethodNotFound = "method not found"
)
// API the assistant API interface
type API interface {
GetPlaceholder(locale string) *store.Placeholder
}
// Script the script scripts except hook script
type Script struct {
*v8.Script
}
// Assistant the assistant
type Assistant struct {
store.AssistantModel
HookScript *hook.Script `json:"-" yaml:"-"` // Hook Script (index.ts)
Scripts map[string]*Script `json:"-" yaml:"-"` // Other scripts
// Internal
// ===============================
vision bool // Whether this assistant supports vision
}
// MCPTool represents a simplified MCP tool for building LLM requests
// This is an internal representation used when collecting tools from MCP servers
// and preparing them for the LLM's tool calling interface
type MCPTool struct {
Name string // Formatted tool name with server prefix (e.g., "server_id__tool_name")
Description string // Tool description from MCP server
Parameters interface{} // JSON Schema for tool parameters (from MCP InputSchema)
}
// ToolCallResult represents the result of a tool call execution
// Used to track the outcome of MCP tool invocations during agent execution
type ToolCallResult struct {
ToolCallID string // Tool call ID from the LLM (matches the ID in the LLM's tool_calls response)
Name string // Tool name (formatted with server prefix, e.g., "server_id__tool_name")
Content string // Result content (JSON string of the tool's output or error message)
Error error // Error if the call failed (nil if successful)
IsRetryableError bool // Whether the error should be sent to LLM for retry
// true: parameter/validation errors that LLM can fix (e.g., "missing required field")
// false: MCP internal errors that LLM cannot fix (e.g., "network error", "service unavailable")
}
// Server extracts the MCP server ID from the formatted tool name
// Example: "echo__ping" -> "echo"
func (r *ToolCallResult) Server() string {
serverID, _, _ := ParseMCPToolName(r.Name)
return serverID
}
// Tool extracts the original tool name without server prefix
// Example: "echo__ping" -> "ping"
func (r *ToolCallResult) Tool() string {
_, toolName, _ := ParseMCPToolName(r.Name)
return toolName
}
// NextProcessContext encapsulates all the context needed to process Next hook responses
// This simplifies function signatures and makes it easier to add new fields in the future
type NextProcessContext struct {
Context *chatctx.Context // Agent context
NextResponse *chatctx.NextHookResponse // Response from Next hook (already converted from JS)
CompletionResponse *chatctx.CompletionResponse // LLM completion response
FullMessages []chatctx.Message // Full conversation history
ToolCallResponses []chatctx.ToolCallResponse // Tool call results (if any)
StreamHandler outputMessage.StreamFunc // Stream handler for output
CreateResponse *chatctx.HookCreateResponse // Create hook response
}
// SearchIntent is an alias for context.SearchIntent
// Used for search intent detection from __yao.needsearch agent
type SearchIntent = chatctx.SearchIntent
// ParsedContent extracts the actual tool return value from MCP ToolContent array
// According to MCP protocol:
// - Content is []ToolContent array
// - For "text" type, the actual value is in Text field (usually JSON string)
// - For "image" type, returns the Data field
// - For "resource" type, returns the Resource object
// If there are multiple content items, returns an array of parsed values
func (r *ToolCallResult) ParsedContent() (interface{}, error) {
if r.Content == "" {
return nil, nil
}
// Parse Content as []ToolContent
var toolContents []map[string]interface{}
if err := jsoniter.UnmarshalFromString(r.Content, &toolContents); err != nil {
// If parsing fails, return the string content directly (error message)
return r.Content, nil
}
// Extract actual values from ToolContent items
var results []interface{}
for _, tc := range toolContents {
contentType, _ := tc["type"].(string)
switch contentType {
case "text":
// For text type, parse the Text field (usually JSON)
if textStr, ok := tc["text"].(string); ok {
// Try to parse as JSON
var parsed interface{}
if err := jsoniter.UnmarshalFromString(textStr, &parsed); err == nil {
results = append(results, parsed)
} else {
// If not JSON, return as plain string
results = append(results, textStr)
}
}
case "image":
// For image type, return the data and mimeType
results = append(results, map[string]interface{}{
"type": "image",
"data": tc["data"],
"mimeType": tc["mimeType"],
})
case "resource":
// For resource type, return the resource object
results = append(results, tc["resource"])
default:
// Unknown type, return as-is
results = append(results, tc)
}
}
// If only one result, return it directly (not as array)
if len(results) == 1 {
return results[0], nil
}
return results, nil
}

View file

@ -1,99 +0,0 @@
package assistant
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"time"
jsoniter "github.com/json-iterator/go"
"github.com/kaptinlin/jsonrepair"
)
func getTimestamp(v interface{}) (int64, error) {
switch v := v.(type) {
case int64:
return v, nil
case int:
return int64(v), nil
case string:
if ts, err := time.Parse(time.RFC3339, v); err == nil {
return ts.UnixNano(), nil
}
// MySQL format
if ts, err := time.Parse("2006-01-02 15:04:05", v); err == nil {
return ts.UnixNano(), nil
}
// UnixNano format
if ts, err := strconv.ParseInt(v, 10, 64); err == nil {
return ts, nil
}
case time.Time:
return v.UnixNano(), nil
case nil:
return 0, nil
}
return 0, fmt.Errorf("invalid timestamp type %T", v)
}
// getBool gets bool from data map[string]interface{}, key string
func getBool(data map[string]interface{}, key string) bool {
switch v := data[key].(type) {
case bool:
return v
case int64:
return v != 0
case int:
return v != 0
case float64:
return v != 0
case string:
return v == "true" || v == "1" || v == "enabled" || v == "yes" || v == "on"
case nil:
return false
}
return false
}
// stringHash returns the sha256 hash of the string
func stringHash(v string) string {
h := sha256.New()
h.Write([]byte(v))
return hex.EncodeToString(h.Sum(nil))
}
// ParseJSON attempts to parse a potentially malformed JSON string
func ParseJSON(jsonStr string, v interface{}) error {
// Try parsing as-is first
err := jsoniter.UnmarshalFromString(jsonStr, v)
if err == nil {
return nil
}
originalErr := err
// Try adding a closing brace
if err := jsoniter.UnmarshalFromString(jsonStr+"}", v); err == nil {
return nil
}
// Try repairing the JSON
repaired, err := jsonrepair.JSONRepair(jsonStr)
if err != nil {
return originalErr
}
// Try parsing the repaired JSON
if err := jsoniter.UnmarshalFromString(repaired, v); err == nil {
return nil
}
// If all attempts fail, return the original error
return originalErr
}

View file

@ -1,21 +0,0 @@
// Package caller provides a shared interface for calling agents
// This package is used by both content and search packages to avoid circular dependencies
package caller
import (
agentContext "github.com/yaoapp/yao/agent/context"
)
// AgentCaller interface for calling agents (to avoid circular dependency)
// Used by content handlers (vision, audio, etc.) and search handlers (agent mode)
type AgentCaller interface {
Stream(ctx *agentContext.Context, messages []agentContext.Message, options ...*agentContext.Options) (*agentContext.Response, error)
}
// AgentGetterFunc is a function type that gets an agent by ID
// This should be set by the assistant package during initialization
var AgentGetterFunc func(agentID string) (AgentCaller, error)
// AssistantReloadFunc reloads a single assistant from disk after deploy.
// Set by the assistant package during initialization.
var AssistantReloadFunc func(id string) error

View file

@ -1,47 +0,0 @@
package caller
import (
"context"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/openapi/oauth/types"
)
// NewHeadlessContext creates a headless agent context from a ProcessCallRequest.
// This is the Process equivalent of openapi.GetCompletionRequest — constructs
// a Context + Options without HTTP dependencies (no Writer, no Interrupt).
//
// Key behaviors:
// - parent context controls timeout/cancellation (caller is responsible)
// - skip.output = true (forced): no Writer available, must skip output
// - skip.history = true (forced): Process calls don't save chat history
// - authorized info is passed in (from authorized.ProcessAuthInfo by caller)
// - chatID is auto-generated if not provided
func NewHeadlessContext(parent context.Context, authInfo *types.AuthorizedInfo, req *ProcessCallRequest) (*agentContext.Context, *agentContext.Options) {
chatID := req.ChatID
if chatID == "" {
chatID = agentContext.GenChatID()
}
ctx := agentContext.New(parent, authInfo, chatID)
ctx.AssistantID = req.AssistantID
ctx.Referer = agentContext.RefererProcess
ctx.Locale = req.Locale
ctx.Route = req.Route
ctx.Metadata = req.Metadata
// Force skip for headless context — no Writer, no chat history
skip := req.Skip
if skip == nil {
skip = &agentContext.Skip{}
}
skip.Output = true // no Writer available
skip.History = true // Process calls don't save chat history
opts := &agentContext.Options{Skip: skip}
if req.Model != "" {
opts.Connector = req.Model
}
return ctx, opts
}

View file

@ -1,11 +0,0 @@
package caller
import (
_ "embed"
"github.com/yaoapp/gou/doc"
)
//go:embed doc.yml
var docYAML []byte
func init() { doc.LoadYAML(docYAML) }

View file

@ -1,13 +0,0 @@
group: agent
type: process
entries:
- name: Call
desc: Call an agent from contexts without agent.Context, enabling agent-to-agent communication
args:
- name: request
type: object
required: true
desc: "Request object with fields: assistant_id (string, required), messages (array of message objects, required), model (string, connector override), skip (object, skip config), metadata (object, passed to hooks), locale (string), route (string), chat_id (string, auto-generated if empty), timeout (number, seconds, default 600)"
return:
type: object
desc: "Result object: { agent_id (string), response (object, full agent response), content (string, extracted text), error (string, error message if failed) }"

View file

@ -1,278 +0,0 @@
package caller_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/assistant"
"github.com/yaoapp/yao/agent/caller"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/openapi/oauth/types"
)
func TestIntegration_Call_RealAgent(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Load the simple-greeting agent
ast, err := assistant.Get("tests.simple-greeting")
require.NoError(t, err)
require.NotNil(t, ast)
// Create authorized info for the context
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
// Create a context with authorization
ctx := agentContext.New(context.Background(), authorized, "test-chat-integration")
ctx.AssistantID = "tests.agent-caller"
// Create JSAPI
api := caller.NewJSAPI(ctx)
// Call the simple-greeting agent
messages := []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello!",
},
}
opts := map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
}
result := api.Call("tests.simple-greeting", messages, opts)
require.NotNil(t, result)
r, ok := result.(*caller.Result)
require.True(t, ok)
assert.Equal(t, "tests.simple-greeting", r.AgentID)
// Should either have content or error
if r.Error != "" {
t.Logf("Agent call error: %s", r.Error)
} else {
t.Logf("Agent response content: %s", r.Content)
assert.NotEmpty(t, r.Content)
}
}
func TestIntegration_All_RealAgents(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Create authorized info for the context
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
// Create a context with authorization
ctx := agentContext.New(context.Background(), authorized, "test-chat-all")
ctx.AssistantID = "tests.agent-caller"
// Create JSAPI
api := caller.NewJSAPI(ctx)
// Call multiple agents in parallel
requests := []interface{}{
map[string]interface{}{
"agent": "tests.simple-greeting",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello from test 1!",
},
},
"options": map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
},
},
map[string]interface{}{
"agent": "tests.simple-greeting",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello from test 2!",
},
},
"options": map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
},
},
}
results := api.All(requests)
require.Len(t, results, 2)
for i, result := range results {
r, ok := result.(*caller.Result)
require.True(t, ok, "result %d should be *caller.Result", i)
assert.Equal(t, "tests.simple-greeting", r.AgentID)
t.Logf("Result[%d]: content=%s, error=%s", i, r.Content, r.Error)
}
}
func TestIntegration_Any_RealAgents(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Create authorized info for the context
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
// Create a context with authorization
ctx := agentContext.New(context.Background(), authorized, "test-chat-any")
ctx.AssistantID = "tests.agent-caller"
// Create JSAPI
api := caller.NewJSAPI(ctx)
// Call multiple agents - return when any succeeds
requests := []interface{}{
map[string]interface{}{
"agent": "tests.simple-greeting",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello from any test 1!",
},
},
"options": map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
},
},
map[string]interface{}{
"agent": "tests.simple-greeting",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello from any test 2!",
},
},
"options": map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
},
},
}
results := api.Any(requests)
require.Len(t, results, 2)
// At least one should have a result
hasResult := false
for i, result := range results {
if result != nil {
r, ok := result.(*caller.Result)
if ok && r != nil && r.Error == "" {
hasResult = true
t.Logf("Any Result[%d]: content=%s", i, r.Content)
}
}
}
assert.True(t, hasResult, "At least one result should succeed")
}
func TestIntegration_Race_RealAgents(t *testing.T) {
if testing.Short() {
t.Skip("Skipping integration test in short mode")
}
testutils.Prepare(t)
defer testutils.Clean(t)
// Create authorized info for the context
authorized := &types.AuthorizedInfo{
Subject: "test-user",
UserID: "test-123",
TenantID: "test-tenant",
}
// Create a context with authorization
ctx := agentContext.New(context.Background(), authorized, "test-chat-race")
ctx.AssistantID = "tests.agent-caller"
// Create JSAPI
api := caller.NewJSAPI(ctx)
// Call multiple agents - return when any completes
requests := []interface{}{
map[string]interface{}{
"agent": "tests.simple-greeting",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello from race test 1!",
},
},
"options": map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
},
},
map[string]interface{}{
"agent": "tests.simple-greeting",
"messages": []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello from race test 2!",
},
},
"options": map[string]interface{}{
"skip": map[string]interface{}{
"history": true,
},
},
},
}
results := api.Race(requests)
require.Len(t, results, 2)
// At least one should have completed
hasResult := false
for i, result := range results {
if result != nil {
r, ok := result.(*caller.Result)
if ok && r != nil {
hasResult = true
t.Logf("Race Result[%d]: content=%s, error=%s", i, r.Content, r.Error)
}
}
}
assert.True(t, hasResult, "At least one result should complete")
}

View file

@ -1,340 +0,0 @@
package caller
import (
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
)
// JSAPI implements context.AgentAPI and context.AgentAPIWithCallback interfaces
// Provides ctx.agent.Call(), ctx.agent.All(), ctx.agent.Any(), ctx.agent.Race()
// and their *WithHandler variants for streaming callback support
type JSAPI struct {
ctx *agentContext.Context
orchestrator *Orchestrator
}
// Ensure JSAPI implements AgentAPIWithCallback
var _ agentContext.AgentAPIWithCallback = (*JSAPI)(nil)
// NewJSAPI creates a new agent JSAPI instance
func NewJSAPI(ctx *agentContext.Context) *JSAPI {
return &JSAPI{
ctx: ctx,
orchestrator: NewOrchestrator(ctx),
}
}
// Call executes a single agent call
// Usage: ctx.agent.Call("assistant-id", messages, options?)
// Returns: { agent_id, response, content, error }
// Note: For sub-agent calls, skip.history = true is automatically set
// to prevent A2A messages from being saved to chat history.
// Sub-agents output normally with ThreadID for SSE stream isolation.
func (api *JSAPI) Call(agentID string, messages []interface{}, opts map[string]interface{}) interface{} {
req := api.buildRequest(agentID, messages, opts)
// Force skip options for sub-agent calls
api.forceSkipForSubAgent(req)
result := api.orchestrator.callAgent(req)
return result
}
// All executes all agent calls and waits for all to complete (like Promise.all)
// Each request should have:
// - agent: string - target agent ID
// - messages: array - messages to send
// - options?: object - call options
func (api *JSAPI) All(requests []interface{}) []interface{} {
reqs := api.parseRequests(requests)
results := api.orchestrator.All(reqs)
return api.convertResults(results)
}
// Any returns as soon as any agent call succeeds (like Promise.any)
// Each request should have:
// - agent: string - target agent ID
// - messages: array - messages to send
// - options?: object - call options
func (api *JSAPI) Any(requests []interface{}) []interface{} {
reqs := api.parseRequests(requests)
results := api.orchestrator.Any(reqs)
return api.convertResults(results)
}
// Race returns as soon as any agent call completes (like Promise.race)
// Each request should have:
// - agent: string - target agent ID
// - messages: array - messages to send
// - options?: object - call options
func (api *JSAPI) Race(requests []interface{}) []interface{} {
reqs := api.parseRequests(requests)
results := api.orchestrator.Race(reqs)
return api.convertResults(results)
}
// ============================================================================
// AgentAPIWithCallback Implementation
// ============================================================================
// CallWithHandler executes a single agent call with an OnMessage handler
// Note: For sub-agent calls, skip.history = true is automatically set.
// Sub-agents output normally with ThreadID. Use the handler callback
// to receive streaming messages.
func (api *JSAPI) CallWithHandler(agentID string, messages []interface{}, opts map[string]interface{}, handler agentContext.OnMessageFunc) interface{} {
req := api.buildRequest(agentID, messages, opts)
req.Handler = handler
// Force skip options for sub-agent calls
api.forceSkipForSubAgent(req)
result := api.orchestrator.callAgent(req)
return result
}
// AllWithHandler executes all agent calls with handlers
func (api *JSAPI) AllWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
results := api.orchestrator.All(reqs)
return api.convertResults(results)
}
// AnyWithHandler executes agent calls and returns on first success, with handlers
func (api *JSAPI) AnyWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
results := api.orchestrator.Any(reqs)
return api.convertResults(results)
}
// RaceWithHandler executes agent calls and returns on first completion, with handlers
func (api *JSAPI) RaceWithHandler(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []interface{} {
reqs := api.parseRequestsWithHandlers(requests, globalHandler)
results := api.orchestrator.Race(reqs)
return api.convertResults(results)
}
// forceSkipForSubAgent ensures proper A2A call behavior:
// - skip.history = true: always set — A2A messages are not saved to chat history
// - skip.output: defaults to false (sub-agents output with ThreadID for SSE stream isolation),
// but if the caller explicitly sets skip.output = true, it is respected.
// This allows internal worker agents (e.g. classifiers) to run silently.
func (api *JSAPI) forceSkipForSubAgent(req *Request) {
if req.Options == nil {
req.Options = &CallOptions{}
}
// Preserve caller's explicit skip.output = true before overwriting Skip struct
callerSkipOutput := req.Options.Skip != nil && req.Options.Skip.Output
if req.Options.Skip == nil {
req.Options.Skip = &agentContext.Skip{}
}
req.Options.Skip.History = true
if callerSkipOutput {
req.Options.Skip.Output = true
}
// else: skip.output remains false (default zero value) — sub-agent outputs normally
}
// parseRequestsWithHandlers parses requests and attaches handlers
// It checks for per-request _handler fields and wraps globalHandler with agentID/index
// For all calls, this automatically sets:
// - skip.history = true: prevents A2A messages from being saved to chat history
// - skip.output = false: ensures sub-agents output with ThreadID (overrides user settings)
func (api *JSAPI) parseRequestsWithHandlers(requests []interface{}, globalHandler agentContext.BatchOnMessageFunc) []*Request {
reqs := make([]*Request, 0, len(requests))
for i, r := range requests {
reqMap, ok := r.(map[string]interface{})
if !ok {
continue
}
// Get agent ID
agentID, ok := reqMap["agent"].(string)
if !ok {
continue
}
// Get messages
messages, ok := reqMap["messages"].([]interface{})
if !ok {
continue
}
// Get options (optional)
var opts map[string]interface{}
if o, ok := reqMap["options"].(map[string]interface{}); ok {
opts = o
}
req := api.buildRequest(agentID, messages, opts)
// Force skip.output = true for all sub-agent calls
api.forceSkipForSubAgent(req)
// Check for per-request handler first (takes precedence)
if handler, ok := reqMap["_handler"].(agentContext.OnMessageFunc); ok && handler != nil {
req.Handler = handler
} else if globalHandler != nil {
// Wrap global handler with agentID and index
idx := i // Capture index for closure
aid := agentID
req.Handler = func(msg *message.Message) int {
return globalHandler(aid, idx, msg)
}
}
reqs = append(reqs, req)
}
return reqs
}
// buildRequest builds a Request from agentID, messages, and options
func (api *JSAPI) buildRequest(agentID string, messages []interface{}, opts map[string]interface{}) *Request {
req := &Request{
AgentID: agentID,
Messages: api.parseMessages(messages),
}
if opts != nil {
req.Options = api.parseCallOptions(opts)
}
return req
}
// parseMessages converts []interface{} to []agentContext.Message
func (api *JSAPI) parseMessages(messages []interface{}) []agentContext.Message {
result := make([]agentContext.Message, 0, len(messages))
for _, m := range messages {
msg, ok := m.(map[string]interface{})
if !ok {
continue
}
ctxMsg := agentContext.Message{}
// Parse role
if role, ok := msg["role"].(string); ok {
ctxMsg.Role = agentContext.MessageRole(role)
}
// Parse content (can be string or array)
ctxMsg.Content = msg["content"]
// Parse name
if name, ok := msg["name"].(string); ok {
ctxMsg.Name = &name
}
// Parse tool_call_id
if toolCallID, ok := msg["tool_call_id"].(string); ok {
ctxMsg.ToolCallID = &toolCallID
}
// Parse tool_calls
if toolCalls, ok := msg["tool_calls"].([]interface{}); ok {
ctxMsg.ToolCalls = api.parseToolCalls(toolCalls)
}
// Parse refusal
if refusal, ok := msg["refusal"].(string); ok {
ctxMsg.Refusal = &refusal
}
result = append(result, ctxMsg)
}
return result
}
// parseToolCalls converts []interface{} to []agentContext.ToolCall
func (api *JSAPI) parseToolCalls(toolCalls []interface{}) []agentContext.ToolCall {
result := make([]agentContext.ToolCall, 0, len(toolCalls))
for _, tc := range toolCalls {
tcMap, ok := tc.(map[string]interface{})
if !ok {
continue
}
toolCall := agentContext.ToolCall{}
if id, ok := tcMap["id"].(string); ok {
toolCall.ID = id
}
if tcType, ok := tcMap["type"].(string); ok {
toolCall.Type = agentContext.ToolCallType(tcType)
}
if fn, ok := tcMap["function"].(map[string]interface{}); ok {
if name, ok := fn["name"].(string); ok {
toolCall.Function.Name = name
}
if args, ok := fn["arguments"].(string); ok {
toolCall.Function.Arguments = args
}
}
result = append(result, toolCall)
}
return result
}
// parseCallOptions converts map to CallOptions
func (api *JSAPI) parseCallOptions(opts map[string]interface{}) *CallOptions {
callOpts := &CallOptions{}
if connector, ok := opts["connector"].(string); ok {
callOpts.Connector = connector
}
if mode, ok := opts["mode"].(string); ok {
callOpts.Mode = mode
}
if metadata, ok := opts["metadata"].(map[string]interface{}); ok {
callOpts.Metadata = metadata
}
// Parse skip configuration
if skip, ok := opts["skip"].(map[string]interface{}); ok {
callOpts.Skip = &agentContext.Skip{}
if history, ok := skip["history"].(bool); ok {
callOpts.Skip.History = history
}
if trace, ok := skip["trace"].(bool); ok {
callOpts.Skip.Trace = trace
}
if output, ok := skip["output"].(bool); ok {
callOpts.Skip.Output = output
}
if keyword, ok := skip["keyword"].(bool); ok {
callOpts.Skip.Keyword = keyword
}
if search, ok := skip["search"].(bool); ok {
callOpts.Skip.Search = search
}
if contentParsing, ok := skip["content_parsing"].(bool); ok {
callOpts.Skip.ContentParsing = contentParsing
}
}
return callOpts
}
// parseRequests parses an array of request objects into typed Requests
func (api *JSAPI) parseRequests(requests []interface{}) []*Request {
return api.parseRequestsWithHandlers(requests, nil)
}
// convertResults converts typed Results to interface slice for JS
func (api *JSAPI) convertResults(results []*Result) []interface{} {
out := make([]interface{}, len(results))
for i, r := range results {
out[i] = r
}
return out
}
// SetJSAPIFactory sets the factory function for creating AgentAPI instances
// Called by assistant package during initialization
func SetJSAPIFactory() {
agentContext.AgentAPIFactory = func(ctx *agentContext.Context) agentContext.AgentAPI {
return NewJSAPI(ctx)
}
}

View file

@ -1,145 +0,0 @@
package caller_test
import (
stdContext "context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/yao/agent/caller"
"github.com/yaoapp/yao/agent/context"
)
func TestNewJSAPI(t *testing.T) {
ctx := context.New(stdContext.Background(), nil, "test-chat")
api := caller.NewJSAPI(ctx)
require.NotNil(t, api)
}
func TestJSAPI_Call_NoAgentGetter(t *testing.T) {
// Reset AgentGetterFunc
originalGetter := caller.AgentGetterFunc
caller.AgentGetterFunc = nil
defer func() { caller.AgentGetterFunc = originalGetter }()
ctx := context.New(stdContext.Background(), nil, "test-chat")
api := caller.NewJSAPI(ctx)
messages := []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello",
},
}
result := api.Call("test-agent", messages, nil)
require.NotNil(t, result)
r, ok := result.(*caller.Result)
require.True(t, ok)
assert.Equal(t, "test-agent", r.AgentID)
assert.Contains(t, r.Error, "agent getter not initialized")
}
func TestJSAPI_All_Empty(t *testing.T) {
ctx := context.New(stdContext.Background(), nil, "test-chat")
api := caller.NewJSAPI(ctx)
results := api.All([]interface{}{})
assert.Len(t, results, 0)
}
func TestJSAPI_Any_Empty(t *testing.T) {
ctx := context.New(stdContext.Background(), nil, "test-chat")
api := caller.NewJSAPI(ctx)
results := api.Any([]interface{}{})
assert.Len(t, results, 0)
}
func TestJSAPI_Race_Empty(t *testing.T) {
ctx := context.New(stdContext.Background(), nil, "test-chat")
api := caller.NewJSAPI(ctx)
results := api.Race([]interface{}{})
assert.Len(t, results, 0)
}
func TestJSAPI_All_InvalidRequests(t *testing.T) {
ctx := context.New(stdContext.Background(), nil, "test-chat")
api := caller.NewJSAPI(ctx)
// Mix of invalid and valid requests
requests := []interface{}{
"invalid", // Not a map
map[string]interface{}{
"messages": []interface{}{}, // Missing agent
},
map[string]interface{}{
"agent": "test-agent", // Missing messages
},
}
results := api.All(requests)
// None should produce a result (all invalid)
assert.Len(t, results, 0)
}
func TestJSAPI_Call_WithOptions(t *testing.T) {
// Reset AgentGetterFunc
originalGetter := caller.AgentGetterFunc
caller.AgentGetterFunc = nil
defer func() { caller.AgentGetterFunc = originalGetter }()
ctx := context.New(stdContext.Background(), nil, "test-chat")
api := caller.NewJSAPI(ctx)
messages := []interface{}{
map[string]interface{}{
"role": "user",
"content": "Hello",
},
}
opts := map[string]interface{}{
"connector": "gpt4",
"mode": "chat",
"metadata": map[string]interface{}{
"key": "value",
},
"skip": map[string]interface{}{
"history": true,
"trace": true,
},
}
result := api.Call("test-agent", messages, opts)
require.NotNil(t, result)
r, ok := result.(*caller.Result)
require.True(t, ok)
assert.Equal(t, "test-agent", r.AgentID)
// Still errors because AgentGetterFunc is nil
assert.Contains(t, r.Error, "agent getter not initialized")
}
func TestSetJSAPIFactory(t *testing.T) {
// Reset factory
context.AgentAPIFactory = nil
// Set factory
caller.SetJSAPIFactory()
// Verify factory is set
require.NotNil(t, context.AgentAPIFactory)
// Create a mock context
ctx := context.New(stdContext.Background(), nil, "test-chat")
// Get agent API
agentAPI := context.AgentAPIFactory(ctx)
require.NotNil(t, agentAPI)
}
func TestJSAPI_ImplementsAgentAPI(t *testing.T) {
// Verify JSAPI implements context.AgentAPI interface
ctx := context.New(stdContext.Background(), nil, "test-chat")
var _ context.AgentAPI = caller.NewJSAPI(ctx)
}

Some files were not shown because too many files have changed in this diff Show more