Compare commits

..

9 Commits

Author SHA1 Message Date
Zamil Majdy
b071cd42f5 Merge remote-tracking branch 'origin/dev' into feat/agent-gen-smart-decision 2026-03-17 06:17:15 +07:00
Zamil Majdy
210a69d33e fix(copilot): update stale docstring for SDM fixer default
The docstring still referenced -1 (infinite) after changing the default
to 10 (bounded).
2026-03-17 00:19:39 +07:00
Zamil Majdy
2ddddc0257 fix(copilot): use bounded default for agent_mode_max_iterations
Change fixer default from -1 (infinite) to 10 (bounded) for safety.
Update guide to let LLM choose iteration count based on task complexity:
1 for single-step, 3-10 for multi-step, -1 for open-ended orchestration.
2026-03-17 00:17:56 +07:00
Zamil Majdy
59f05ed23c fix(copilot): guard SDM input_default type and reject invalid iterations
- Handle input_default=None by replacing with empty dict in fixer
- Reject agent_mode_max_iterations < -1 in validator (only -1 or
  positive values are valid)
- Add tests for both edge cases
2026-03-16 22:41:40 +07:00
Zamil Majdy
09fd30e14f fix(copilot): reject agent_mode_max_iterations=0 in SDM validator
Traditional mode (0) requires complex external conversation-history
loop wiring that the agent generator does not produce. Validate that
generated SDM nodes use agent mode (-1 or positive) only.
2026-03-16 22:25:43 +07:00
Zamil Majdy
ef6118c640 fix(copilot): treat explicit null SDM fields as missing defaults
The fixer now treats `None` values in SDM input_default as missing,
overwriting them with the correct defaults. This handles cases where
the LLM generates `null` for fields it doesn't know the value of.
2026-03-16 22:21:39 +07:00
Zamil Majdy
5d527eab85 fix(copilot): address review comments on SDM validator and tests
- Filter out AgentInput/OutputBlocks from SDM tool link check (interface
  blocks aren't real tools)
- Add test for interface-block-only links failing validation
- Add test for explicit None values being treated as missing in fixer
- Assert full graph validity in e2e pipeline test
2026-03-16 22:19:45 +07:00
Zamil Majdy
f0af149f16 fix(copilot): keep SmartDecisionMakerBlock excluded from CoPilot standalone
Address review feedback:
- Revert find_block.py exclusion removal — SDM requires graph context
  and would crash if run via run_block (missing execution_processor)
- The guide hardcodes the block ID so agent generation still works
- Add warning against agent_mode_max_iterations=0 in guide
- Hoist _DEFAULTS to module-level _SDM_DEFAULTS constant in fixer.py
2026-03-16 21:58:57 +07:00
Zamil Majdy
5ad71099ac feat(copilot): enable SmartDecisionMakerBlock in agent generator
Allow the agent generator to create orchestrator agents that use
SmartDecisionMakerBlock with agent mode to autonomously decide which
tools or sub-agents to call in a loop until the task is complete.

Changes:
- Remove SmartDecisionMakerBlock from COPILOT_EXCLUDED_BLOCK_IDS
- Add SMART_DECISION_MAKER_BLOCK_ID constant to helpers
- Add fixer to populate agent-mode defaults (max_iterations=-1, etc.)
- Add validator to ensure downstream tool blocks are connected
- Document SmartDecisionMakerBlock usage in agent_generation_guide.md
- Add 18 tests covering fixer, validator, and e2e pipeline
2026-03-16 21:51:24 +07:00
28 changed files with 1081 additions and 5153 deletions

View File

@@ -5,14 +5,12 @@ on:
branches: [master, dev, ci-test*]
paths:
- ".github/workflows/platform-backend-ci.yml"
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
- "autogpt_platform/backend/**"
- "autogpt_platform/autogpt_libs/**"
pull_request:
branches: [master, dev, release-*]
paths:
- ".github/workflows/platform-backend-ci.yml"
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
- "autogpt_platform/backend/**"
- "autogpt_platform/autogpt_libs/**"
merge_group:

View File

@@ -120,6 +120,175 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
exitOnceUploaded: true
e2e_test:
name: end-to-end tests
runs-on: big-boi
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Platform - Copy default supabase .env
run: |
cp ../.env.default ../.env
- name: Set up Platform - Copy backend .env and set OpenAI API key
run: |
cp ../backend/.env.default ../backend/.env
echo "OPENAI_INTERNAL_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> ../backend/.env
env:
# Used by E2E test data script to generate embeddings for approved store agents
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Set up Platform - Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- name: Set up Platform - Expose GHA cache to docker buildx CLI
uses: crazy-max/ghaction-github-runtime@v4
- name: Set up Platform - Build Docker images (with cache)
working-directory: autogpt_platform
run: |
pip install pyyaml
# Resolve extends and generate a flat compose file that bake can understand
docker compose -f docker-compose.yml config > docker-compose.resolved.yml
# Add cache configuration to the resolved compose file
python ../.github/workflows/scripts/docker-ci-fix-compose-build-cache.py \
--source docker-compose.resolved.yml \
--cache-from "type=gha" \
--cache-to "type=gha,mode=max" \
--backend-hash "${{ hashFiles('autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/poetry.lock', 'autogpt_platform/backend/backend') }}" \
--frontend-hash "${{ hashFiles('autogpt_platform/frontend/Dockerfile', 'autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/src') }}" \
--git-ref "${{ github.ref }}"
# Build with bake using the resolved compose file (now includes cache config)
docker buildx bake --allow=fs.read=.. -f docker-compose.resolved.yml --load
env:
NEXT_PUBLIC_PW_TEST: true
- name: Set up tests - Cache E2E test data
id: e2e-data-cache
uses: actions/cache@v5
with:
path: /tmp/e2e_test_data.sql
key: e2e-test-data-${{ hashFiles('autogpt_platform/backend/test/e2e_test_data.py', 'autogpt_platform/backend/migrations/**', '.github/workflows/platform-frontend-ci.yml') }}
- name: Set up Platform - Start Supabase DB + Auth
run: |
docker compose -f ../docker-compose.resolved.yml up -d db auth --no-build
echo "Waiting for database to be ready..."
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done'
echo "Waiting for auth service to be ready..."
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -c "SELECT 1 FROM auth.users LIMIT 1" 2>/dev/null; do sleep 2; done' || echo "Auth schema check timeout, continuing..."
- name: Set up Platform - Run migrations
run: |
echo "Running migrations..."
docker compose -f ../docker-compose.resolved.yml run --rm migrate
echo "✅ Migrations completed"
env:
NEXT_PUBLIC_PW_TEST: true
- name: Set up tests - Load cached E2E test data
if: steps.e2e-data-cache.outputs.cache-hit == 'true'
run: |
echo "✅ Found cached E2E test data, restoring..."
{
echo "SET session_replication_role = 'replica';"
cat /tmp/e2e_test_data.sql
echo "SET session_replication_role = 'origin';"
} | docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -b
# Refresh materialized views after restore
docker compose -f ../docker-compose.resolved.yml exec -T db \
psql -U postgres -d postgres -b -c "SET search_path TO platform; SELECT refresh_store_materialized_views();" || true
echo "✅ E2E test data restored from cache"
- name: Set up Platform - Start (all other services)
run: |
docker compose -f ../docker-compose.resolved.yml up -d --no-build
echo "Waiting for rest_server to be ready..."
timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..."
env:
NEXT_PUBLIC_PW_TEST: true
- name: Set up tests - Create E2E test data
if: steps.e2e-data-cache.outputs.cache-hit != 'true'
run: |
echo "Creating E2E test data..."
docker cp ../backend/test/e2e_test_data.py $(docker compose -f ../docker-compose.resolved.yml ps -q rest_server):/tmp/e2e_test_data.py
docker compose -f ../docker-compose.resolved.yml exec -T rest_server sh -c "cd /app/autogpt_platform && python /tmp/e2e_test_data.py" || {
echo "❌ E2E test data creation failed!"
docker compose -f ../docker-compose.resolved.yml logs --tail=50 rest_server
exit 1
}
# Dump auth.users + platform schema for cache (two separate dumps)
echo "Dumping database for cache..."
{
docker compose -f ../docker-compose.resolved.yml exec -T db \
pg_dump -U postgres --data-only --column-inserts \
--table='auth.users' postgres
docker compose -f ../docker-compose.resolved.yml exec -T db \
pg_dump -U postgres --data-only --column-inserts \
--schema=platform \
--exclude-table='platform._prisma_migrations' \
--exclude-table='platform.apscheduler_jobs' \
--exclude-table='platform.apscheduler_jobs_batched_notifications' \
postgres
} > /tmp/e2e_test_data.sql
echo "✅ Database dump created for caching ($(wc -l < /tmp/e2e_test_data.sql) lines)"
- name: Set up tests - Enable corepack
run: corepack enable
- name: Set up tests - Set up Node
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
- name: Set up tests - Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up tests - Install browser 'chromium'
run: pnpm playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm test:no-build
continue-on-error: false
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
if-no-files-found: ignore
retention-days: 3
- name: Upload Playwright test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: test-results
if-no-files-found: ignore
retention-days: 3
- name: Print Final Docker Compose logs
if: always()
run: docker compose -f ../docker-compose.resolved.yml logs
integration_test:
runs-on: ubuntu-latest
needs: setup

View File

@@ -1,18 +1,14 @@
name: AutoGPT Platform - Full-stack CI
name: AutoGPT Platform - Frontend CI
on:
push:
branches: [master, dev]
paths:
- ".github/workflows/platform-fullstack-ci.yml"
- ".github/workflows/scripts/docker-ci-fix-compose-build-cache.py"
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
- "autogpt_platform/**"
pull_request:
paths:
- ".github/workflows/platform-fullstack-ci.yml"
- ".github/workflows/scripts/docker-ci-fix-compose-build-cache.py"
- ".github/workflows/scripts/get_package_version_from_lockfile.py"
- "autogpt_platform/**"
merge_group:
@@ -28,28 +24,42 @@ defaults:
jobs:
setup:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache-key.outputs.key }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Enable corepack
run: corepack enable
- name: Set up Node
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
- name: Install dependencies to populate cache
- name: Enable corepack
run: corepack enable
- name: Generate cache key
id: cache-key
run: echo "key=${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/package.json') }}" >> $GITHUB_OUTPUT
- name: Cache dependencies
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: ${{ steps.cache-key.outputs.key }}
restore-keys: |
${{ runner.os }}-pnpm-${{ hashFiles('autogpt_platform/frontend/pnpm-lock.yaml') }}
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
check-api-types:
name: check API types
runs-on: ubuntu-latest
types:
runs-on: big-boi
needs: setup
strategy:
fail-fast: false
steps:
- name: Checkout repository
@@ -57,256 +67,70 @@ jobs:
with:
submodules: recursive
# ------------------------ Backend setup ------------------------
- name: Set up Backend - Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Backend - Install Poetry
working-directory: autogpt_platform/backend
run: |
POETRY_VERSION=$(python ../../.github/workflows/scripts/get_package_version_from_lockfile.py poetry)
echo "Installing Poetry version ${POETRY_VERSION}"
curl -sSL https://install.python-poetry.org | POETRY_VERSION=$POETRY_VERSION python3 -
- name: Set up Backend - Set up dependency cache
uses: actions/cache@v5
with:
path: ~/.cache/pypoetry
key: poetry-${{ runner.os }}-${{ hashFiles('autogpt_platform/backend/poetry.lock') }}
- name: Set up Backend - Install dependencies
working-directory: autogpt_platform/backend
run: poetry install
- name: Set up Backend - Generate Prisma client
working-directory: autogpt_platform/backend
run: poetry run prisma generate && poetry run gen-prisma-stub
- name: Set up Frontend - Export OpenAPI schema from Backend
working-directory: autogpt_platform/backend
run: poetry run export-api-schema --output ../frontend/src/app/api/openapi.json
# ------------------------ Frontend setup ------------------------
- name: Set up Frontend - Enable corepack
run: corepack enable
- name: Set up Frontend - Set up Node
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
- name: Set up Frontend - Install dependencies
- name: Enable corepack
run: corepack enable
- name: Copy default supabase .env
run: |
cp ../.env.default ../.env
- name: Copy backend .env
run: |
cp ../backend/.env.default ../backend/.env
- name: Run docker compose
run: |
docker compose -f ../docker-compose.yml --profile local up -d deps_backend
- name: Restore dependencies cache
uses: actions/cache@v5
with:
path: ~/.pnpm-store
key: ${{ needs.setup.outputs.cache-key }}
restore-keys: |
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up Frontend - Format OpenAPI schema
id: format-schema
run: pnpm prettier --write ./src/app/api/openapi.json
- name: Setup .env
run: cp .env.default .env
- name: Wait for services to be ready
run: |
echo "Waiting for rest_server to be ready..."
timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..."
echo "Waiting for database to be ready..."
timeout 60 sh -c 'until docker compose -f ../docker-compose.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done' || echo "Database ready check timeout, continuing..."
- name: Generate API queries
run: pnpm generate:api:force
- name: Check for API schema changes
run: |
if ! git diff --exit-code src/app/api/openapi.json; then
echo "❌ API schema changes detected in src/app/api/openapi.json"
echo ""
echo "The openapi.json file has been modified after exporting the API schema."
echo "The openapi.json file has been modified after running 'pnpm generate:api-all'."
echo "This usually means changes have been made in the BE endpoints without updating the Frontend."
echo "The API schema is now out of sync with the Front-end queries."
echo ""
echo "To fix this:"
echo "\nIn the backend directory:"
echo "1. Run 'poetry run export-api-schema --output ../frontend/src/app/api/openapi.json'"
echo "\nIn the frontend directory:"
echo "2. Run 'pnpm prettier --write src/app/api/openapi.json'"
echo "3. Run 'pnpm generate:api'"
echo "4. Run 'pnpm types'"
echo "5. Fix any TypeScript errors that may have been introduced"
echo "6. Commit and push your changes"
echo "1. Pull the backend 'docker compose pull && docker compose up -d --build --force-recreate'"
echo "2. Run 'pnpm generate:api' locally"
echo "3. Run 'pnpm types' locally"
echo "4. Fix any TypeScript errors that may have been introduced"
echo "5. Commit and push your changes"
echo ""
exit 1
else
echo "✅ No API schema changes detected"
fi
- name: Set up Frontend - Generate API client
id: generate-api-client
run: pnpm orval --config ./orval.config.ts
# Continue with type generation & check even if there are schema changes
if: success() || (steps.format-schema.outcome == 'success')
- name: Check for TypeScript errors
- name: Run Typescript checks
run: pnpm types
if: success() || (steps.generate-api-client.outcome == 'success')
e2e_test:
name: end-to-end tests
runs-on: big-boi
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
submodules: recursive
- name: Set up Platform - Copy default supabase .env
run: |
cp ../.env.default ../.env
- name: Set up Platform - Copy backend .env and set OpenAI API key
run: |
cp ../backend/.env.default ../backend/.env
echo "OPENAI_INTERNAL_API_KEY=${{ secrets.OPENAI_API_KEY }}" >> ../backend/.env
env:
# Used by E2E test data script to generate embeddings for approved store agents
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
- name: Set up Platform - Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: docker-container
driver-opts: network=host
- name: Set up Platform - Expose GHA cache to docker buildx CLI
uses: crazy-max/ghaction-github-runtime@v4
- name: Set up Platform - Build Docker images (with cache)
working-directory: autogpt_platform
run: |
pip install pyyaml
# Resolve extends and generate a flat compose file that bake can understand
docker compose -f docker-compose.yml config > docker-compose.resolved.yml
# Add cache configuration to the resolved compose file
python ../.github/workflows/scripts/docker-ci-fix-compose-build-cache.py \
--source docker-compose.resolved.yml \
--cache-from "type=gha" \
--cache-to "type=gha,mode=max" \
--backend-hash "${{ hashFiles('autogpt_platform/backend/Dockerfile', 'autogpt_platform/backend/poetry.lock', 'autogpt_platform/backend/backend/**') }}" \
--frontend-hash "${{ hashFiles('autogpt_platform/frontend/Dockerfile', 'autogpt_platform/frontend/pnpm-lock.yaml', 'autogpt_platform/frontend/src/**') }}" \
--git-ref "${{ github.ref }}"
# Build with bake using the resolved compose file (now includes cache config)
docker buildx bake --allow=fs.read=.. -f docker-compose.resolved.yml --load
env:
NEXT_PUBLIC_PW_TEST: true
- name: Set up tests - Cache E2E test data
id: e2e-data-cache
uses: actions/cache@v5
with:
path: /tmp/e2e_test_data.sql
key: e2e-test-data-${{ hashFiles('autogpt_platform/backend/test/e2e_test_data.py', 'autogpt_platform/backend/migrations/**', '.github/workflows/platform-fullstack-ci.yml') }}
- name: Set up Platform - Start Supabase DB + Auth
run: |
docker compose -f ../docker-compose.resolved.yml up -d db auth --no-build
echo "Waiting for database to be ready..."
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db pg_isready -U postgres 2>/dev/null; do sleep 2; done'
echo "Waiting for auth service to be ready..."
timeout 60 sh -c 'until docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -c "SELECT 1 FROM auth.users LIMIT 1" 2>/dev/null; do sleep 2; done' || echo "Auth schema check timeout, continuing..."
- name: Set up Platform - Run migrations
run: |
echo "Running migrations..."
docker compose -f ../docker-compose.resolved.yml run --rm migrate
echo "✅ Migrations completed"
env:
NEXT_PUBLIC_PW_TEST: true
- name: Set up tests - Load cached E2E test data
if: steps.e2e-data-cache.outputs.cache-hit == 'true'
run: |
echo "✅ Found cached E2E test data, restoring..."
{
echo "SET session_replication_role = 'replica';"
cat /tmp/e2e_test_data.sql
echo "SET session_replication_role = 'origin';"
} | docker compose -f ../docker-compose.resolved.yml exec -T db psql -U postgres -d postgres -b
# Refresh materialized views after restore
docker compose -f ../docker-compose.resolved.yml exec -T db \
psql -U postgres -d postgres -b -c "SET search_path TO platform; SELECT refresh_store_materialized_views();" || true
echo "✅ E2E test data restored from cache"
- name: Set up Platform - Start (all other services)
run: |
docker compose -f ../docker-compose.resolved.yml up -d --no-build
echo "Waiting for rest_server to be ready..."
timeout 60 sh -c 'until curl -f http://localhost:8006/health 2>/dev/null; do sleep 2; done' || echo "Rest server health check timeout, continuing..."
env:
NEXT_PUBLIC_PW_TEST: true
- name: Set up tests - Create E2E test data
if: steps.e2e-data-cache.outputs.cache-hit != 'true'
run: |
echo "Creating E2E test data..."
docker cp ../backend/test/e2e_test_data.py $(docker compose -f ../docker-compose.resolved.yml ps -q rest_server):/tmp/e2e_test_data.py
docker compose -f ../docker-compose.resolved.yml exec -T rest_server sh -c "cd /app/autogpt_platform && python /tmp/e2e_test_data.py" || {
echo "❌ E2E test data creation failed!"
docker compose -f ../docker-compose.resolved.yml logs --tail=50 rest_server
exit 1
}
# Dump auth.users + platform schema for cache (two separate dumps)
echo "Dumping database for cache..."
{
docker compose -f ../docker-compose.resolved.yml exec -T db \
pg_dump -U postgres --data-only --column-inserts \
--table='auth.users' postgres
docker compose -f ../docker-compose.resolved.yml exec -T db \
pg_dump -U postgres --data-only --column-inserts \
--schema=platform \
--exclude-table='platform._prisma_migrations' \
--exclude-table='platform.apscheduler_jobs' \
--exclude-table='platform.apscheduler_jobs_batched_notifications' \
postgres
} > /tmp/e2e_test_data.sql
echo "✅ Database dump created for caching ($(wc -l < /tmp/e2e_test_data.sql) lines)"
- name: Set up tests - Enable corepack
run: corepack enable
- name: Set up tests - Set up Node
uses: actions/setup-node@v6
with:
node-version: "22.18.0"
cache: "pnpm"
cache-dependency-path: autogpt_platform/frontend/pnpm-lock.yaml
- name: Set up tests - Install dependencies
run: pnpm install --frozen-lockfile
- name: Set up tests - Install browser 'chromium'
run: pnpm playwright install --with-deps chromium
- name: Run Playwright tests
run: pnpm test:no-build
continue-on-error: false
- name: Upload Playwright report
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report
if-no-files-found: ignore
retention-days: 3
- name: Upload Playwright test results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-test-results
path: test-results
if-no-files-found: ignore
retention-days: 3
- name: Print Final Docker Compose logs
if: always()
run: docker compose -f ../docker-compose.resolved.yml logs

View File

@@ -178,16 +178,6 @@ yield "image_url", result_url
3. Write tests alongside the route file
4. Run `poetry run test` to verify
## Workspace & Media Files
**Read [Workspace & Media Architecture](../../docs/platform/workspace-media-architecture.md) when:**
- Working on CoPilot file upload/download features
- Building blocks that handle `MediaFileType` inputs/outputs
- Modifying `WorkspaceManager` or `store_media_file()`
- Debugging file persistence or virus scanning issues
Covers: `WorkspaceManager` (persistent storage with session scoping), `store_media_file()` (media normalization pipeline), and responsibility boundaries for virus scanning and persistence.
## Security Implementation
### Cache Protection Middleware

View File

@@ -40,7 +40,7 @@ from backend.copilot.response_model import (
from backend.copilot.service import (
_build_system_prompt,
_generate_session_title,
_get_openai_client,
client,
config,
)
from backend.copilot.tools import execute_tool, get_available_tools
@@ -89,7 +89,7 @@ async def _compress_session_messages(
result = await compress_context(
messages=messages_dict,
model=config.model,
client=_get_openai_client(),
client=client,
)
except Exception as e:
logger.warning("[Baseline] Context compression with LLM failed: %s", e)
@@ -235,7 +235,7 @@ async def stream_chat_completion_baseline(
)
if tools:
create_kwargs["tools"] = tools
response = await _get_openai_client().chat.completions.create(**create_kwargs) # type: ignore[arg-type] # dynamic kwargs
response = await client.chat.completions.create(**create_kwargs) # type: ignore[arg-type] # dynamic kwargs
# Accumulate streamed response (text + tool calls)
round_text = ""

View File

@@ -143,6 +143,48 @@ To use an MCP (Model Context Protocol) tool as a node in the agent:
tool_arguments.
6. Output: `result` (the tool's return value) and `error` (error message)
### Using SmartDecisionMakerBlock (AI Orchestrator with Agent Mode)
To create an agent where AI autonomously decides which tools or sub-agents to
call in a loop until the task is complete:
1. Create a `SmartDecisionMakerBlock` node
(ID: `3b191d9f-356f-482d-8238-ba04b6d18381`)
2. Set `input_default`:
- `agent_mode_max_iterations`: Choose based on task complexity:
- `1` for single-step tool calls (AI picks one tool, calls it, done)
- `3``10` for multi-step tasks (AI calls tools iteratively)
- `-1` for open-ended orchestration (AI loops until it decides it's done)
Do NOT use `0` (traditional mode) — it requires complex external
conversation-history loop wiring that the agent generator does not
produce.
- `conversation_compaction`: `true` (recommended to avoid context overflow)
- Optional: `sys_prompt` for extra LLM context about how to orchestrate
3. Wire the `prompt` input from an `AgentInputBlock` (the user's task)
4. Create downstream tool blocks — regular blocks **or** `AgentExecutorBlock`
nodes that call sub-agents
5. Link each tool to the SmartDecisionMaker: set `source_name: "tools"` on
the SmartDecisionMaker side and `sink_name: <input_field>` on each tool
block's input. Create one link per input field the tool needs.
6. Wire the `finished` output to an `AgentOutputBlock` for the final result
7. Credentials (LLM API key) are configured by the user in the platform UI
after saving — do NOT require them upfront
**Example — Orchestrator calling two sub-agents:**
- Node 1: `AgentInputBlock` (input_default: `{"name": "task"}`)
- Node 2: `SmartDecisionMakerBlock` (input_default:
`{"agent_mode_max_iterations": 10, "conversation_compaction": true}`)
- Node 3: `AgentExecutorBlock` (sub-agent A — set `graph_id`, `graph_version`,
`input_schema`, `output_schema` from library agent)
- Node 4: `AgentExecutorBlock` (sub-agent B — same pattern)
- Node 5: `AgentOutputBlock` (input_default: `{"name": "result"}`)
- Links:
- Input→SDM: `source_name: "result"`, `sink_name: "prompt"`
- SDM→Agent A (per input field): `source_name: "tools"`,
`sink_name: "<agent_a_input_field>"`
- SDM→Agent B (per input field): `source_name: "tools"`,
`sink_name: "<agent_b_input_field>"`
- SDM→Output: `source_name: "finished"`, `sink_name: "value"`
### Example: Simple AI Text Processor
A minimal agent with input, processing, and output:

View File

@@ -28,24 +28,10 @@ logger = logging.getLogger(__name__)
config = ChatConfig()
settings = Settings()
_client: LangfuseAsyncOpenAI | None = None
_langfuse = None
client = LangfuseAsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
def _get_openai_client() -> LangfuseAsyncOpenAI:
global _client
if _client is None:
_client = LangfuseAsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
return _client
def _get_langfuse():
global _langfuse
if _langfuse is None:
_langfuse = get_client()
return _langfuse
langfuse = get_client()
# Default system prompt used when Langfuse is not configured
# Provides minimal baseline tone and personality - all workflow, tools, and
@@ -98,7 +84,7 @@ async def _get_system_prompt_template(context: str) -> str:
else "latest"
)
prompt = await asyncio.to_thread(
_get_langfuse().get_prompt,
langfuse.get_prompt,
config.langfuse_prompt_name,
label=label,
cache_ttl_seconds=config.langfuse_prompt_cache_ttl,
@@ -172,7 +158,7 @@ async def _generate_session_title(
"environment": settings.config.app_env.value,
}
response = await _get_openai_client().chat.completions.create(
response = await client.chat.completions.create(
model=config.title_model,
messages=[
{

View File

@@ -7,6 +7,7 @@ from typing import Any
from .helpers import (
AGENT_EXECUTOR_BLOCK_ID,
MCP_TOOL_BLOCK_ID,
SMART_DECISION_MAKER_BLOCK_ID,
AgentDict,
are_types_compatible,
generate_uuid,
@@ -30,6 +31,14 @@ _GET_CURRENT_DATE_BLOCK_ID = "b29c1b50-5d0e-4d9f-8f9d-1b0e6fcbf0b1"
_GMAIL_SEND_BLOCK_ID = "6c27abc2-e51d-499e-a85f-5a0041ba94f0"
_TEXT_REPLACE_BLOCK_ID = "7e7c87ab-3469-4bcc-9abe-67705091b713"
# Defaults applied to SmartDecisionMakerBlock nodes by the fixer.
_SDM_DEFAULTS: dict[str, object] = {
"agent_mode_max_iterations": 10,
"conversation_compaction": True,
"retry": 3,
"multiple_tool_calls": False,
}
class AgentFixer:
"""
@@ -1630,6 +1639,43 @@ class AgentFixer:
return agent
def fix_smart_decision_maker_blocks(self, agent: AgentDict) -> AgentDict:
"""Fix SmartDecisionMakerBlock nodes to ensure agent-mode defaults.
Ensures:
1. ``agent_mode_max_iterations`` defaults to ``10`` (bounded agent mode)
2. ``conversation_compaction`` defaults to ``True``
3. ``retry`` defaults to ``3``
4. ``multiple_tool_calls`` defaults to ``False``
Args:
agent: The agent dictionary to fix
Returns:
The fixed agent dictionary
"""
nodes = agent.get("nodes", [])
for node in nodes:
if node.get("block_id") != SMART_DECISION_MAKER_BLOCK_ID:
continue
node_id = node.get("id", "unknown")
input_default = node.get("input_default")
if not isinstance(input_default, dict):
input_default = {}
node["input_default"] = input_default
for field, default_value in _SDM_DEFAULTS.items():
if field not in input_default or input_default[field] is None:
input_default[field] = default_value
self.add_fix_log(
f"SmartDecisionMakerBlock {node_id}: "
f"Set {field}={default_value!r}"
)
return agent
def fix_dynamic_block_sink_names(self, agent: AgentDict) -> AgentDict:
"""Fix links that use _#_ notation for dynamic block sink names.
@@ -1717,6 +1763,9 @@ class AgentFixer:
# Apply fixes for MCPToolBlock nodes
agent = self.fix_mcp_tool_blocks(agent)
# Apply fixes for SmartDecisionMakerBlock nodes (agent-mode defaults)
agent = self.fix_smart_decision_maker_blocks(agent)
# Apply fixes for AgentExecutorBlock nodes (sub-agents)
if library_agents:
agent = self.fix_agent_executor_blocks(agent, library_agents)

View File

@@ -12,6 +12,7 @@ __all__ = [
"AGENT_OUTPUT_BLOCK_ID",
"AgentDict",
"MCP_TOOL_BLOCK_ID",
"SMART_DECISION_MAKER_BLOCK_ID",
"UUID_REGEX",
"are_types_compatible",
"generate_uuid",
@@ -33,6 +34,7 @@ UUID_REGEX = re.compile(r"^" + UUID_RE_STR + r"$")
AGENT_EXECUTOR_BLOCK_ID = "e189baac-8c20-45a1-94a7-55177ea42565"
MCP_TOOL_BLOCK_ID = "a0a4b1c2-d3e4-4f56-a7b8-c9d0e1f2a3b4"
SMART_DECISION_MAKER_BLOCK_ID = "3b191d9f-356f-482d-8238-ba04b6d18381"
AGENT_INPUT_BLOCK_ID = "c0a8e994-ebf1-4a9c-a4d8-89d09c86741b"
AGENT_OUTPUT_BLOCK_ID = "363ae599-353e-4804-937e-b2ee3cef3da4"

View File

@@ -10,6 +10,7 @@ from .helpers import (
AGENT_INPUT_BLOCK_ID,
AGENT_OUTPUT_BLOCK_ID,
MCP_TOOL_BLOCK_ID,
SMART_DECISION_MAKER_BLOCK_ID,
AgentDict,
are_types_compatible,
get_defined_property_type,
@@ -809,6 +810,73 @@ class AgentValidator:
return valid
def validate_smart_decision_maker_blocks(self, agent: AgentDict) -> bool:
"""Validate that SmartDecisionMakerBlock nodes have downstream tools.
Checks that each SmartDecisionMakerBlock node has at least one link
with ``source_name == "tools"`` connecting to a downstream block.
Without tools, the block has nothing to call and will error at runtime.
Returns True if all SmartDecisionMakerBlock nodes are valid.
"""
valid = True
nodes = agent.get("nodes", [])
links = agent.get("links", [])
node_lookup = {node.get("id", ""): node for node in nodes}
non_tool_block_ids = {AGENT_INPUT_BLOCK_ID, AGENT_OUTPUT_BLOCK_ID}
for node in nodes:
if node.get("block_id") != SMART_DECISION_MAKER_BLOCK_ID:
continue
node_id = node.get("id", "unknown")
customized_name = (node.get("metadata") or {}).get(
"customized_name", node_id
)
# Warn if agent_mode_max_iterations is 0 (traditional mode) —
# requires complex external conversation-history loop wiring
# that the agent generator does not produce.
input_default = node.get("input_default", {})
max_iter = input_default.get("agent_mode_max_iterations")
if isinstance(max_iter, int) and max_iter < -1:
self.add_error(
f"SmartDecisionMakerBlock node '{customized_name}' "
f"({node_id}) has invalid "
f"agent_mode_max_iterations={max_iter}. "
f"Use -1 for infinite or a positive number for "
f"bounded iterations."
)
valid = False
elif max_iter == 0:
self.add_error(
f"SmartDecisionMakerBlock node '{customized_name}' "
f"({node_id}) has agent_mode_max_iterations=0 "
f"(traditional mode). The agent generator only supports "
f"agent mode (set to -1 for infinite or a positive "
f"number for bounded iterations)."
)
valid = False
has_tools = any(
link.get("source_id") == node_id
and link.get("source_name") == "tools"
and node_lookup.get(link.get("sink_id", ""), {}).get("block_id")
not in non_tool_block_ids
for link in links
)
if not has_tools:
self.add_error(
f"SmartDecisionMakerBlock node '{customized_name}' "
f"({node_id}) has no downstream tool blocks connected. "
f"Connect at least one block to its 'tools' output so "
f"the AI has tools to call."
)
valid = False
return valid
def validate_mcp_tool_blocks(self, agent: AgentDict) -> bool:
"""Validate that MCPToolBlock nodes have required fields.
@@ -913,6 +981,10 @@ class AgentValidator:
"MCP tool blocks",
self.validate_mcp_tool_blocks(agent),
),
(
"SmartDecisionMaker blocks",
self.validate_smart_decision_maker_blocks(agent),
),
]
# Add AgentExecutorBlock detailed validation if library_agents

View File

@@ -37,7 +37,8 @@ COPILOT_EXCLUDED_BLOCK_TYPES = {
# Specific block IDs excluded from CoPilot (STANDARD type but still require graph context)
COPILOT_EXCLUDED_BLOCK_IDS = {
# SmartDecisionMakerBlock - dynamically discovers downstream blocks via graph topology
# SmartDecisionMakerBlock - dynamically discovers downstream blocks via graph topology;
# usable in agent graphs (guide hardcodes its ID) but cannot run standalone.
"3b191d9f-356f-482d-8238-ba04b6d18381",
}

View File

@@ -183,8 +183,7 @@ class WorkspaceManager:
f"{Config().max_file_size_mb}MB limit"
)
# Scan here — callers must NOT duplicate this scan.
# WorkspaceManager owns virus scanning for all persisted files.
# Virus scan content before persisting (defense in depth)
await scan_content_safe(content, filename=filename)
# Determine path with session scoping

View File

@@ -0,0 +1,668 @@
"""
Tests for SmartDecisionMakerBlock support in agent generator.
Covers:
- AgentFixer.fix_smart_decision_maker_blocks()
- AgentValidator.validate_smart_decision_maker_blocks()
- End-to-end fix → validate → pipeline for SmartDecisionMaker agents
"""
import uuid
from backend.copilot.tools.agent_generator.fixer import AgentFixer
from backend.copilot.tools.agent_generator.helpers import (
AGENT_EXECUTOR_BLOCK_ID,
AGENT_INPUT_BLOCK_ID,
AGENT_OUTPUT_BLOCK_ID,
SMART_DECISION_MAKER_BLOCK_ID,
)
from backend.copilot.tools.agent_generator.validator import AgentValidator
def _uid() -> str:
return str(uuid.uuid4())
def _make_sdm_node(
node_id: str | None = None,
input_default: dict | None = None,
metadata: dict | None = None,
) -> dict:
"""Create a SmartDecisionMakerBlock node dict."""
return {
"id": node_id or _uid(),
"block_id": SMART_DECISION_MAKER_BLOCK_ID,
"input_default": input_default or {},
"metadata": metadata or {"position": {"x": 0, "y": 0}},
}
def _make_agent_executor_node(
node_id: str | None = None,
graph_id: str | None = None,
) -> dict:
"""Create an AgentExecutorBlock node dict."""
return {
"id": node_id or _uid(),
"block_id": AGENT_EXECUTOR_BLOCK_ID,
"input_default": {
"graph_id": graph_id or _uid(),
"graph_version": 1,
"input_schema": {"properties": {"query": {"type": "string"}}},
"output_schema": {"properties": {"result": {"type": "string"}}},
"user_id": "",
"inputs": {},
},
"metadata": {"position": {"x": 800, "y": 0}},
}
def _make_input_node(node_id: str | None = None, name: str = "task") -> dict:
return {
"id": node_id or _uid(),
"block_id": AGENT_INPUT_BLOCK_ID,
"input_default": {"name": name, "title": name.title()},
"metadata": {"position": {"x": -800, "y": 0}},
}
def _make_output_node(node_id: str | None = None, name: str = "result") -> dict:
return {
"id": node_id or _uid(),
"block_id": AGENT_OUTPUT_BLOCK_ID,
"input_default": {"name": name, "title": name.title()},
"metadata": {"position": {"x": 1600, "y": 0}},
}
def _link(
source_id: str,
source_name: str,
sink_id: str,
sink_name: str,
is_static: bool = False,
) -> dict:
return {
"id": _uid(),
"source_id": source_id,
"source_name": source_name,
"sink_id": sink_id,
"sink_name": sink_name,
"is_static": is_static,
}
def _make_orchestrator_agent() -> dict:
"""Build a complete orchestrator agent with SDM + 2 sub-agent tools."""
input_node = _make_input_node()
sdm_node = _make_sdm_node()
agent_a = _make_agent_executor_node()
agent_b = _make_agent_executor_node()
output_node = _make_output_node()
return {
"id": _uid(),
"version": 1,
"is_active": True,
"name": "Orchestrator Agent",
"description": "Uses AI to orchestrate sub-agents",
"nodes": [input_node, sdm_node, agent_a, agent_b, output_node],
"links": [
# Input → SDM prompt
_link(input_node["id"], "result", sdm_node["id"], "prompt"),
# SDM tools → Agent A
_link(sdm_node["id"], "tools", agent_a["id"], "query"),
# SDM tools → Agent B
_link(sdm_node["id"], "tools", agent_b["id"], "query"),
# SDM finished → Output
_link(sdm_node["id"], "finished", output_node["id"], "value"),
],
}
# ---------------------------------------------------------------------------
# Fixer tests
# ---------------------------------------------------------------------------
class TestFixSmartDecisionMakerBlocks:
"""Tests for AgentFixer.fix_smart_decision_maker_blocks()."""
def test_fills_defaults_when_missing(self):
"""All agent-mode defaults are populated for a bare SDM node."""
fixer = AgentFixer()
agent = {"nodes": [_make_sdm_node()], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10
assert defaults["conversation_compaction"] is True
assert defaults["retry"] == 3
assert defaults["multiple_tool_calls"] is False
assert len(fixer.fixes_applied) == 4
def test_preserves_existing_values(self):
"""Existing user-set values are never overwritten."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(
input_default={
"agent_mode_max_iterations": 5,
"conversation_compaction": False,
"retry": 1,
"multiple_tool_calls": True,
}
)
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 5
assert defaults["conversation_compaction"] is False
assert defaults["retry"] == 1
assert defaults["multiple_tool_calls"] is True
assert len(fixer.fixes_applied) == 0
def test_partial_defaults(self):
"""Only missing fields are filled; existing ones are kept."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(
input_default={
"agent_mode_max_iterations": 10,
}
)
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10 # kept
assert defaults["conversation_compaction"] is True # filled
assert defaults["retry"] == 3 # filled
assert defaults["multiple_tool_calls"] is False # filled
assert len(fixer.fixes_applied) == 3
def test_skips_non_sdm_nodes(self):
"""Non-SmartDecisionMaker nodes are untouched."""
fixer = AgentFixer()
other_node = {
"id": _uid(),
"block_id": AGENT_INPUT_BLOCK_ID,
"input_default": {"name": "test"},
"metadata": {},
}
agent = {"nodes": [other_node], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
assert "agent_mode_max_iterations" not in result["nodes"][0]["input_default"]
assert len(fixer.fixes_applied) == 0
def test_handles_missing_input_default(self):
"""Node with no input_default key gets one created."""
fixer = AgentFixer()
node = {
"id": _uid(),
"block_id": SMART_DECISION_MAKER_BLOCK_ID,
"metadata": {},
}
agent = {"nodes": [node], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
assert "input_default" in result["nodes"][0]
assert result["nodes"][0]["input_default"]["agent_mode_max_iterations"] == 10
def test_handles_none_input_default(self):
"""Node with input_default set to None gets a dict created."""
fixer = AgentFixer()
node = {
"id": _uid(),
"block_id": SMART_DECISION_MAKER_BLOCK_ID,
"input_default": None,
"metadata": {},
}
agent = {"nodes": [node], "links": []}
result = fixer.fix_smart_decision_maker_blocks(agent)
assert isinstance(result["nodes"][0]["input_default"], dict)
assert result["nodes"][0]["input_default"]["agent_mode_max_iterations"] == 10
def test_treats_none_values_as_missing(self):
"""Explicit None values are overwritten with defaults."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(
input_default={
"agent_mode_max_iterations": None,
"conversation_compaction": None,
"retry": 3,
"multiple_tool_calls": False,
}
)
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10 # None → default
assert defaults["conversation_compaction"] is True # None → default
assert defaults["retry"] == 3 # kept
assert defaults["multiple_tool_calls"] is False # kept
assert len(fixer.fixes_applied) == 2
def test_multiple_sdm_nodes(self):
"""Multiple SDM nodes are all fixed independently."""
fixer = AgentFixer()
agent = {
"nodes": [
_make_sdm_node(input_default={"agent_mode_max_iterations": 3}),
_make_sdm_node(input_default={}),
],
"links": [],
}
result = fixer.fix_smart_decision_maker_blocks(agent)
# First node: 3 defaults filled (agent_mode was already set)
assert result["nodes"][0]["input_default"]["agent_mode_max_iterations"] == 3
# Second node: all 4 defaults filled
assert result["nodes"][1]["input_default"]["agent_mode_max_iterations"] == 10
assert len(fixer.fixes_applied) == 7 # 3 + 4
def test_registered_in_apply_all_fixes(self):
"""fix_smart_decision_maker_blocks runs as part of apply_all_fixes."""
fixer = AgentFixer()
agent = {
"nodes": [_make_sdm_node()],
"links": [],
}
result = fixer.apply_all_fixes(agent)
defaults = result["nodes"][0]["input_default"]
assert defaults["agent_mode_max_iterations"] == 10
assert any("SmartDecisionMakerBlock" in fix for fix in fixer.fixes_applied)
# ---------------------------------------------------------------------------
# Validator tests
# ---------------------------------------------------------------------------
class TestValidateSmartDecisionMakerBlocks:
"""Tests for AgentValidator.validate_smart_decision_maker_blocks()."""
def test_valid_sdm_with_tools(self):
"""SDM with downstream tool links passes validation."""
validator = AgentValidator()
agent = _make_orchestrator_agent()
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is True
assert len(validator.errors) == 0
def test_sdm_without_tools_fails(self):
"""SDM with no 'tools' links fails validation."""
validator = AgentValidator()
sdm = _make_sdm_node()
agent = {
"nodes": [sdm],
"links": [], # no tool links
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
assert "no downstream tool blocks" in validator.errors[0]
def test_sdm_with_non_tools_links_fails(self):
"""Links that don't use source_name='tools' don't count."""
validator = AgentValidator()
sdm = _make_sdm_node()
other = _make_agent_executor_node()
agent = {
"nodes": [sdm, other],
"links": [
# Link from 'finished' output, not 'tools'
_link(sdm["id"], "finished", other["id"], "query"),
],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
def test_no_sdm_nodes_passes(self):
"""Agent without SmartDecisionMaker nodes passes trivially."""
validator = AgentValidator()
agent = {
"nodes": [_make_input_node(), _make_output_node()],
"links": [],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is True
assert len(validator.errors) == 0
def test_error_includes_customized_name(self):
"""Error message includes the node's customized_name if set."""
validator = AgentValidator()
sdm = _make_sdm_node(
metadata={
"position": {"x": 0, "y": 0},
"customized_name": "My Orchestrator",
}
)
agent = {"nodes": [sdm], "links": []}
validator.validate_smart_decision_maker_blocks(agent)
assert "My Orchestrator" in validator.errors[0]
def test_multiple_sdm_nodes_mixed(self):
"""One valid and one invalid SDM node: only the invalid one errors."""
validator = AgentValidator()
sdm_valid = _make_sdm_node()
sdm_invalid = _make_sdm_node()
tool = _make_agent_executor_node()
agent = {
"nodes": [sdm_valid, sdm_invalid, tool],
"links": [
_link(sdm_valid["id"], "tools", tool["id"], "query"),
# sdm_invalid has no tool links
],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
assert sdm_invalid["id"] in validator.errors[0]
def test_sdm_with_traditional_mode_fails(self):
"""agent_mode_max_iterations=0 (traditional mode) is rejected."""
validator = AgentValidator()
sdm = _make_sdm_node(input_default={"agent_mode_max_iterations": 0})
tool = _make_agent_executor_node()
agent = {
"nodes": [sdm, tool],
"links": [_link(sdm["id"], "tools", tool["id"], "query")],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert any("agent_mode_max_iterations=0" in e for e in validator.errors)
def test_sdm_with_negative_iterations_below_minus_one_fails(self):
"""agent_mode_max_iterations < -1 is rejected."""
validator = AgentValidator()
sdm = _make_sdm_node(input_default={"agent_mode_max_iterations": -5})
tool = _make_agent_executor_node()
agent = {
"nodes": [sdm, tool],
"links": [_link(sdm["id"], "tools", tool["id"], "query")],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert any("invalid" in e and "-5" in e for e in validator.errors)
def test_sdm_with_only_interface_block_links_fails(self):
"""Links to AgentInput/OutputBlocks don't count as tool connections."""
validator = AgentValidator()
sdm = _make_sdm_node()
input_node = _make_input_node()
output_node = _make_output_node()
agent = {
"nodes": [sdm, input_node, output_node],
"links": [
# These link to interface blocks, not real tools
_link(sdm["id"], "tools", input_node["id"], "name"),
_link(sdm["id"], "tools", output_node["id"], "value"),
],
}
result = validator.validate_smart_decision_maker_blocks(agent)
assert result is False
assert len(validator.errors) == 1
assert "no downstream tool blocks" in validator.errors[0]
def test_registered_in_validate(self):
"""validate_smart_decision_maker_blocks runs as part of validate()."""
validator = AgentValidator()
sdm = _make_sdm_node()
agent = {
"id": _uid(),
"version": 1,
"is_active": True,
"name": "Test",
"description": "test",
"nodes": [sdm, _make_input_node(), _make_output_node()],
"links": [],
}
# Build a minimal blocks list with the SDM block info
blocks = [
{
"id": SMART_DECISION_MAKER_BLOCK_ID,
"name": "SmartDecisionMakerBlock",
"inputSchema": {"properties": {"prompt": {"type": "string"}}},
"outputSchema": {
"properties": {
"tools": {},
"finished": {"type": "string"},
"conversations": {"type": "array"},
}
},
},
{
"id": AGENT_INPUT_BLOCK_ID,
"name": "AgentInputBlock",
"inputSchema": {
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
"outputSchema": {"properties": {"result": {}}},
},
{
"id": AGENT_OUTPUT_BLOCK_ID,
"name": "AgentOutputBlock",
"inputSchema": {
"properties": {
"name": {"type": "string"},
"value": {},
},
"required": ["name"],
},
"outputSchema": {"properties": {"output": {}}},
},
]
is_valid, error_msg = validator.validate(agent, blocks)
assert is_valid is False
assert error_msg is not None
assert "no downstream tool blocks" in error_msg
# ---------------------------------------------------------------------------
# E2E pipeline test: fix → validate for a complete orchestrator agent
# ---------------------------------------------------------------------------
class TestSmartDecisionMakerE2EPipeline:
"""End-to-end tests: build agent JSON → fix → validate."""
def test_orchestrator_agent_fix_then_validate(self):
"""A well-formed orchestrator agent passes fix + validate."""
agent = _make_orchestrator_agent()
# Fix
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
# Verify defaults were applied
sdm_nodes = [
n for n in fixed["nodes"] if n["block_id"] == SMART_DECISION_MAKER_BLOCK_ID
]
assert len(sdm_nodes) == 1
assert sdm_nodes[0]["input_default"]["agent_mode_max_iterations"] == 10
assert sdm_nodes[0]["input_default"]["conversation_compaction"] is True
# Validate (standalone SDM check)
validator = AgentValidator()
assert validator.validate_smart_decision_maker_blocks(fixed) is True
def test_bare_sdm_no_tools_fix_then_validate(self):
"""SDM without tools: fixer fills defaults, validator catches error."""
input_node = _make_input_node()
sdm_node = _make_sdm_node()
output_node = _make_output_node()
agent = {
"id": _uid(),
"version": 1,
"is_active": True,
"name": "Bare SDM Agent",
"description": "SDM with no tools",
"nodes": [input_node, sdm_node, output_node],
"links": [
_link(input_node["id"], "result", sdm_node["id"], "prompt"),
_link(sdm_node["id"], "finished", output_node["id"], "value"),
],
}
# Fix fills defaults fine
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
assert fixed["nodes"][1]["input_default"]["agent_mode_max_iterations"] == 10
# Validate catches missing tools
validator = AgentValidator()
assert validator.validate_smart_decision_maker_blocks(fixed) is False
assert any("no downstream tool blocks" in e for e in validator.errors)
def test_sdm_with_user_set_bounded_iterations(self):
"""User-set bounded iterations are preserved through fix pipeline."""
agent = _make_orchestrator_agent()
# Simulate user setting bounded iterations
for node in agent["nodes"]:
if node["block_id"] == SMART_DECISION_MAKER_BLOCK_ID:
node["input_default"]["agent_mode_max_iterations"] = 5
node["input_default"]["sys_prompt"] = "You are a helpful orchestrator"
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
sdm = next(
n for n in fixed["nodes"] if n["block_id"] == SMART_DECISION_MAKER_BLOCK_ID
)
assert sdm["input_default"]["agent_mode_max_iterations"] == 5
assert sdm["input_default"]["sys_prompt"] == "You are a helpful orchestrator"
# Other defaults still filled
assert sdm["input_default"]["conversation_compaction"] is True
assert sdm["input_default"]["retry"] == 3
def test_full_pipeline_with_blocks_list(self):
"""Full validate() with blocks list for a valid orchestrator agent."""
agent = _make_orchestrator_agent()
fixer = AgentFixer()
fixed = fixer.apply_all_fixes(agent)
blocks = [
{
"id": SMART_DECISION_MAKER_BLOCK_ID,
"name": "SmartDecisionMakerBlock",
"inputSchema": {
"properties": {
"prompt": {"type": "string"},
"model": {"type": "object"},
"sys_prompt": {"type": "string"},
"agent_mode_max_iterations": {"type": "integer"},
"conversation_compaction": {"type": "boolean"},
"retry": {"type": "integer"},
"multiple_tool_calls": {"type": "boolean"},
},
"required": ["prompt"],
},
"outputSchema": {
"properties": {
"tools": {},
"finished": {"type": "string"},
"conversations": {"type": "array"},
}
},
},
{
"id": AGENT_EXECUTOR_BLOCK_ID,
"name": "AgentExecutorBlock",
"inputSchema": {
"properties": {
"graph_id": {"type": "string"},
"graph_version": {"type": "integer"},
"input_schema": {"type": "object"},
"output_schema": {"type": "object"},
"user_id": {"type": "string"},
"inputs": {"type": "object"},
"query": {"type": "string"},
},
"required": ["graph_id"],
},
"outputSchema": {
"properties": {"result": {"type": "string"}},
},
},
{
"id": AGENT_INPUT_BLOCK_ID,
"name": "AgentInputBlock",
"inputSchema": {
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
"outputSchema": {"properties": {"result": {}}},
},
{
"id": AGENT_OUTPUT_BLOCK_ID,
"name": "AgentOutputBlock",
"inputSchema": {
"properties": {
"name": {"type": "string"},
"value": {},
},
"required": ["name"],
},
"outputSchema": {"properties": {"output": {}}},
},
]
validator = AgentValidator()
is_valid, error_msg = validator.validate(fixed, blocks)
# Full graph validation should pass
assert is_valid, f"Validation failed: {error_msg}"
# SDM-specific validation should pass (has tool links)
sdm_errors = [e for e in validator.errors if "SmartDecisionMakerBlock" in e]
assert len(sdm_errors) == 0, f"Unexpected SDM errors: {sdm_errors}"

View File

@@ -1,440 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { screen, cleanup } from "@testing-library/react";
import { render } from "@/tests/integrations/test-utils";
import React from "react";
import { BlockUIType } from "../components/types";
import type {
CustomNodeData,
CustomNode as CustomNodeType,
} from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import type { NodeProps } from "@xyflow/react";
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
// ---- Mock sub-components ----
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeContainer",
() => ({
NodeContainer: ({
children,
hasErrors,
}: {
children: React.ReactNode;
hasErrors: boolean;
}) => (
<div data-testid="node-container" data-has-errors={String(!!hasErrors)}>
{children}
</div>
),
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeHeader",
() => ({
NodeHeader: ({ data }: { data: CustomNodeData }) => (
<div data-testid="node-header">{data.title}</div>
),
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/StickyNoteBlock",
() => ({
StickyNoteBlock: ({ data }: { data: CustomNodeData }) => (
<div data-testid="sticky-note-block">{data.title}</div>
),
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeAdvancedToggle",
() => ({
NodeAdvancedToggle: () => <div data-testid="node-advanced-toggle" />,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeOutput/NodeOutput",
() => ({
NodeDataRenderer: () => <div data-testid="node-data-renderer" />,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeExecutionBadge",
() => ({
NodeExecutionBadge: () => <div data-testid="node-execution-badge" />,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/NodeRightClickMenu",
() => ({
NodeRightClickMenu: ({ children }: { children: React.ReactNode }) => (
<div data-testid="node-right-click-menu">{children}</div>
),
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/WebhookDisclaimer",
() => ({
WebhookDisclaimer: () => <div data-testid="webhook-disclaimer" />,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/SubAgentUpdate/SubAgentUpdateFeature",
() => ({
SubAgentUpdateFeature: () => <div data-testid="sub-agent-update" />,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/components/AyrshareConnectButton",
() => ({
AyrshareConnectButton: () => <div data-testid="ayrshare-connect-button" />,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/FormCreator",
() => ({
FormCreator: () => <div data-testid="form-creator" />,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/OutputHandler",
() => ({
OutputHandler: () => <div data-testid="output-handler" />,
}),
);
vi.mock(
"@/components/renderers/InputRenderer/utils/input-schema-pre-processor",
() => ({
preprocessInputSchema: (schema: unknown) => schema,
}),
);
vi.mock(
"@/app/(platform)/build/components/FlowEditor/nodes/CustomNode/useCustomNode",
() => ({
useCustomNode: ({ data }: { data: CustomNodeData }) => ({
inputSchema: data.inputSchema,
outputSchema: data.outputSchema,
isMCPWithTool: false,
}),
}),
);
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual("@xyflow/react");
return {
...actual,
useReactFlow: () => ({
getNodes: () => [],
getEdges: () => [],
setNodes: vi.fn(),
setEdges: vi.fn(),
getNode: vi.fn(),
}),
useNodeId: () => "test-node-id",
useUpdateNodeInternals: () => vi.fn(),
Handle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
Position: { Left: "left", Right: "right", Top: "top", Bottom: "bottom" },
};
});
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
// ---- Helpers ----
function buildNodeData(
overrides: Partial<CustomNodeData> = {},
): CustomNodeData {
return {
hardcodedValues: {},
title: "Test Block",
description: "A test block",
inputSchema: { type: "object", properties: {} },
outputSchema: { type: "object", properties: {} },
uiType: BlockUIType.STANDARD,
block_id: "block-123",
costs: [],
categories: [],
...overrides,
};
}
function buildNodeProps(
dataOverrides: Partial<CustomNodeData> = {},
propsOverrides: Partial<NodeProps<CustomNodeType>> = {},
): NodeProps<CustomNodeType> {
return {
id: "node-1",
data: buildNodeData(dataOverrides),
selected: false,
type: "custom",
isConnectable: true,
positionAbsoluteX: 0,
positionAbsoluteY: 0,
zIndex: 0,
dragging: false,
dragHandle: undefined,
draggable: true,
selectable: true,
deletable: true,
parentId: undefined,
width: undefined,
height: undefined,
sourcePosition: undefined,
targetPosition: undefined,
...propsOverrides,
};
}
function renderCustomNode(
dataOverrides: Partial<CustomNodeData> = {},
propsOverrides: Partial<NodeProps<CustomNodeType>> = {},
) {
const props = buildNodeProps(dataOverrides, propsOverrides);
return render(<CustomNode {...props} />);
}
function createExecutionResult(
overrides: Partial<NodeExecutionResult> = {},
): NodeExecutionResult {
return {
node_exec_id: overrides.node_exec_id ?? "exec-1",
node_id: overrides.node_id ?? "node-1",
graph_exec_id: overrides.graph_exec_id ?? "graph-exec-1",
graph_id: overrides.graph_id ?? "graph-1",
graph_version: overrides.graph_version ?? 1,
user_id: overrides.user_id ?? "test-user",
block_id: overrides.block_id ?? "block-1",
status: overrides.status ?? "COMPLETED",
input_data: overrides.input_data ?? {},
output_data: overrides.output_data ?? {},
add_time: overrides.add_time ?? new Date("2024-01-01T00:00:00Z"),
queue_time: overrides.queue_time ?? new Date("2024-01-01T00:00:00Z"),
start_time: overrides.start_time ?? new Date("2024-01-01T00:00:01Z"),
end_time: overrides.end_time ?? new Date("2024-01-01T00:00:02Z"),
};
}
// ---- Tests ----
beforeEach(() => {
cleanup();
});
describe("CustomNode", () => {
describe("STANDARD type rendering", () => {
it("renders NodeHeader with the block title", () => {
renderCustomNode({ title: "My Standard Block" });
const header = screen.getByTestId("node-header");
expect(header).toBeDefined();
expect(header.textContent).toContain("My Standard Block");
});
it("renders NodeContainer, FormCreator, OutputHandler, and NodeExecutionBadge", () => {
renderCustomNode();
expect(screen.getByTestId("node-container")).toBeDefined();
expect(screen.getByTestId("form-creator")).toBeDefined();
expect(screen.getByTestId("output-handler")).toBeDefined();
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
expect(screen.getByTestId("node-data-renderer")).toBeDefined();
expect(screen.getByTestId("node-advanced-toggle")).toBeDefined();
});
it("wraps content in NodeRightClickMenu", () => {
renderCustomNode();
expect(screen.getByTestId("node-right-click-menu")).toBeDefined();
});
it("does not render StickyNoteBlock for STANDARD type", () => {
renderCustomNode();
expect(screen.queryByTestId("sticky-note-block")).toBeNull();
});
});
describe("NOTE type rendering", () => {
it("renders StickyNoteBlock instead of main UI", () => {
renderCustomNode({ uiType: BlockUIType.NOTE, title: "My Note" });
const note = screen.getByTestId("sticky-note-block");
expect(note).toBeDefined();
expect(note.textContent).toContain("My Note");
});
it("does not render NodeContainer or other standard components", () => {
renderCustomNode({ uiType: BlockUIType.NOTE });
expect(screen.queryByTestId("node-container")).toBeNull();
expect(screen.queryByTestId("node-header")).toBeNull();
expect(screen.queryByTestId("form-creator")).toBeNull();
expect(screen.queryByTestId("output-handler")).toBeNull();
});
});
describe("WEBHOOK type rendering", () => {
it("renders WebhookDisclaimer for WEBHOOK type", () => {
renderCustomNode({ uiType: BlockUIType.WEBHOOK });
expect(screen.getByTestId("webhook-disclaimer")).toBeDefined();
});
it("renders WebhookDisclaimer for WEBHOOK_MANUAL type", () => {
renderCustomNode({ uiType: BlockUIType.WEBHOOK_MANUAL });
expect(screen.getByTestId("webhook-disclaimer")).toBeDefined();
});
});
describe("AGENT type rendering", () => {
it("renders SubAgentUpdateFeature for AGENT type", () => {
renderCustomNode({ uiType: BlockUIType.AGENT });
expect(screen.getByTestId("sub-agent-update")).toBeDefined();
});
it("does not render SubAgentUpdateFeature for non-AGENT types", () => {
renderCustomNode({ uiType: BlockUIType.STANDARD });
expect(screen.queryByTestId("sub-agent-update")).toBeNull();
});
});
describe("OUTPUT type rendering", () => {
it("does not render OutputHandler for OUTPUT type", () => {
renderCustomNode({ uiType: BlockUIType.OUTPUT });
expect(screen.queryByTestId("output-handler")).toBeNull();
});
it("still renders FormCreator and other components for OUTPUT type", () => {
renderCustomNode({ uiType: BlockUIType.OUTPUT });
expect(screen.getByTestId("form-creator")).toBeDefined();
expect(screen.getByTestId("node-header")).toBeDefined();
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
});
});
describe("AYRSHARE type rendering", () => {
it("renders AyrshareConnectButton for AYRSHARE type", () => {
renderCustomNode({ uiType: BlockUIType.AYRSHARE });
expect(screen.getByTestId("ayrshare-connect-button")).toBeDefined();
});
it("does not render AyrshareConnectButton for non-AYRSHARE types", () => {
renderCustomNode({ uiType: BlockUIType.STANDARD });
expect(screen.queryByTestId("ayrshare-connect-button")).toBeNull();
});
});
describe("error states", () => {
it("sets hasErrors on NodeContainer when data.errors has non-empty values", () => {
renderCustomNode({
errors: { field1: "This field is required" },
});
const container = screen.getByTestId("node-container");
expect(container.getAttribute("data-has-errors")).toBe("true");
});
it("does not set hasErrors when data.errors is empty", () => {
renderCustomNode({ errors: {} });
const container = screen.getByTestId("node-container");
expect(container.getAttribute("data-has-errors")).toBe("false");
});
it("does not set hasErrors when data.errors values are all empty strings", () => {
renderCustomNode({ errors: { field1: "" } });
const container = screen.getByTestId("node-container");
expect(container.getAttribute("data-has-errors")).toBe("false");
});
it("sets hasErrors when last execution result has error in output_data", () => {
renderCustomNode({
nodeExecutionResults: [
createExecutionResult({
output_data: { error: ["Something went wrong"] },
}),
],
});
const container = screen.getByTestId("node-container");
expect(container.getAttribute("data-has-errors")).toBe("true");
});
it("does not set hasErrors when execution results have no error", () => {
renderCustomNode({
nodeExecutionResults: [
createExecutionResult({
output_data: { result: ["success"] },
}),
],
});
const container = screen.getByTestId("node-container");
expect(container.getAttribute("data-has-errors")).toBe("false");
});
});
describe("NodeExecutionBadge", () => {
it("always renders NodeExecutionBadge for non-NOTE types", () => {
renderCustomNode({ uiType: BlockUIType.STANDARD });
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
});
it("renders NodeExecutionBadge for AGENT type", () => {
renderCustomNode({ uiType: BlockUIType.AGENT });
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
});
it("renders NodeExecutionBadge for OUTPUT type", () => {
renderCustomNode({ uiType: BlockUIType.OUTPUT });
expect(screen.getByTestId("node-execution-badge")).toBeDefined();
});
});
describe("edge cases", () => {
it("renders without nodeExecutionResults", () => {
renderCustomNode({ nodeExecutionResults: undefined });
const container = screen.getByTestId("node-container");
expect(container).toBeDefined();
expect(container.getAttribute("data-has-errors")).toBe("false");
});
it("renders without errors property", () => {
renderCustomNode({ errors: undefined });
const container = screen.getByTestId("node-container");
expect(container).toBeDefined();
expect(container.getAttribute("data-has-errors")).toBe("false");
});
it("renders with empty execution results array", () => {
renderCustomNode({ nodeExecutionResults: [] });
const container = screen.getByTestId("node-container");
expect(container).toBeDefined();
expect(container.getAttribute("data-has-errors")).toBe("false");
});
});
});

View File

@@ -1,342 +0,0 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
render,
screen,
fireEvent,
waitFor,
cleanup,
} from "@/tests/integrations/test-utils";
import { useBlockMenuStore } from "../stores/blockMenuStore";
import { useControlPanelStore } from "../stores/controlPanelStore";
import { DefaultStateType } from "../components/NewControlPanel/NewBlockMenu/types";
import { SearchEntryFilterAnyOfItem } from "@/app/api/__generated__/models/searchEntryFilterAnyOfItem";
// ---------------------------------------------------------------------------
// Mocks for heavy child components
// ---------------------------------------------------------------------------
vi.mock(
"../components/NewControlPanel/NewBlockMenu/BlockMenuDefault/BlockMenuDefault",
() => ({
BlockMenuDefault: () => (
<div data-testid="block-menu-default">Default Content</div>
),
}),
);
vi.mock(
"../components/NewControlPanel/NewBlockMenu/BlockMenuSearch/BlockMenuSearch",
() => ({
BlockMenuSearch: () => (
<div data-testid="block-menu-search">Search Results</div>
),
}),
);
// Mock query client used by the search bar hook
vi.mock("@/lib/react-query/queryClient", () => ({
getQueryClient: () => ({
invalidateQueries: vi.fn(),
}),
}));
// ---------------------------------------------------------------------------
// Reset stores before each test
// ---------------------------------------------------------------------------
afterEach(() => {
cleanup();
});
beforeEach(() => {
useBlockMenuStore.getState().reset();
useBlockMenuStore.setState({
filters: [],
creators: [],
creators_list: [],
categoryCounts: {
blocks: 0,
integrations: 0,
marketplace_agents: 0,
my_agents: 0,
},
});
useControlPanelStore.getState().reset();
});
// ===========================================================================
// Section 1: blockMenuStore unit tests
// ===========================================================================
describe("blockMenuStore", () => {
describe("searchQuery", () => {
it("defaults to an empty string", () => {
expect(useBlockMenuStore.getState().searchQuery).toBe("");
});
it("sets the search query", () => {
useBlockMenuStore.getState().setSearchQuery("timer");
expect(useBlockMenuStore.getState().searchQuery).toBe("timer");
});
});
describe("defaultState", () => {
it("defaults to SUGGESTION", () => {
expect(useBlockMenuStore.getState().defaultState).toBe(
DefaultStateType.SUGGESTION,
);
});
it("sets the default state", () => {
useBlockMenuStore.getState().setDefaultState(DefaultStateType.ALL_BLOCKS);
expect(useBlockMenuStore.getState().defaultState).toBe(
DefaultStateType.ALL_BLOCKS,
);
});
});
describe("filters", () => {
it("defaults to an empty array", () => {
expect(useBlockMenuStore.getState().filters).toEqual([]);
});
it("adds a filter", () => {
useBlockMenuStore.getState().addFilter(SearchEntryFilterAnyOfItem.blocks);
expect(useBlockMenuStore.getState().filters).toEqual([
SearchEntryFilterAnyOfItem.blocks,
]);
});
it("removes a filter", () => {
useBlockMenuStore
.getState()
.setFilters([
SearchEntryFilterAnyOfItem.blocks,
SearchEntryFilterAnyOfItem.integrations,
]);
useBlockMenuStore
.getState()
.removeFilter(SearchEntryFilterAnyOfItem.blocks);
expect(useBlockMenuStore.getState().filters).toEqual([
SearchEntryFilterAnyOfItem.integrations,
]);
});
it("replaces all filters with setFilters", () => {
useBlockMenuStore.getState().addFilter(SearchEntryFilterAnyOfItem.blocks);
useBlockMenuStore
.getState()
.setFilters([SearchEntryFilterAnyOfItem.marketplace_agents]);
expect(useBlockMenuStore.getState().filters).toEqual([
SearchEntryFilterAnyOfItem.marketplace_agents,
]);
});
});
describe("creators", () => {
it("adds a creator", () => {
useBlockMenuStore.getState().addCreator("alice");
expect(useBlockMenuStore.getState().creators).toEqual(["alice"]);
});
it("removes a creator", () => {
useBlockMenuStore.getState().setCreators(["alice", "bob"]);
useBlockMenuStore.getState().removeCreator("alice");
expect(useBlockMenuStore.getState().creators).toEqual(["bob"]);
});
it("replaces all creators with setCreators", () => {
useBlockMenuStore.getState().addCreator("alice");
useBlockMenuStore.getState().setCreators(["charlie"]);
expect(useBlockMenuStore.getState().creators).toEqual(["charlie"]);
});
});
describe("categoryCounts", () => {
it("sets category counts", () => {
const counts = {
blocks: 10,
integrations: 5,
marketplace_agents: 3,
my_agents: 2,
};
useBlockMenuStore.getState().setCategoryCounts(counts);
expect(useBlockMenuStore.getState().categoryCounts).toEqual(counts);
});
});
describe("searchId", () => {
it("defaults to undefined", () => {
expect(useBlockMenuStore.getState().searchId).toBeUndefined();
});
it("sets and clears searchId", () => {
useBlockMenuStore.getState().setSearchId("search-123");
expect(useBlockMenuStore.getState().searchId).toBe("search-123");
useBlockMenuStore.getState().setSearchId(undefined);
expect(useBlockMenuStore.getState().searchId).toBeUndefined();
});
});
describe("integration", () => {
it("defaults to undefined", () => {
expect(useBlockMenuStore.getState().integration).toBeUndefined();
});
it("sets the integration", () => {
useBlockMenuStore.getState().setIntegration("slack");
expect(useBlockMenuStore.getState().integration).toBe("slack");
});
});
describe("reset", () => {
it("resets searchQuery, searchId, defaultState, and integration", () => {
useBlockMenuStore.getState().setSearchQuery("hello");
useBlockMenuStore.getState().setSearchId("id-1");
useBlockMenuStore.getState().setDefaultState(DefaultStateType.ALL_BLOCKS);
useBlockMenuStore.getState().setIntegration("github");
useBlockMenuStore.getState().reset();
const state = useBlockMenuStore.getState();
expect(state.searchQuery).toBe("");
expect(state.searchId).toBeUndefined();
expect(state.defaultState).toBe(DefaultStateType.SUGGESTION);
expect(state.integration).toBeUndefined();
});
it("does not reset filters or creators (by design)", () => {
useBlockMenuStore
.getState()
.setFilters([SearchEntryFilterAnyOfItem.blocks]);
useBlockMenuStore.getState().setCreators(["alice"]);
useBlockMenuStore.getState().reset();
expect(useBlockMenuStore.getState().filters).toEqual([
SearchEntryFilterAnyOfItem.blocks,
]);
expect(useBlockMenuStore.getState().creators).toEqual(["alice"]);
});
});
});
// ===========================================================================
// Section 2: controlPanelStore unit tests
// ===========================================================================
describe("controlPanelStore", () => {
it("defaults blockMenuOpen to false", () => {
expect(useControlPanelStore.getState().blockMenuOpen).toBe(false);
});
it("sets blockMenuOpen", () => {
useControlPanelStore.getState().setBlockMenuOpen(true);
expect(useControlPanelStore.getState().blockMenuOpen).toBe(true);
});
it("sets forceOpenBlockMenu", () => {
useControlPanelStore.getState().setForceOpenBlockMenu(true);
expect(useControlPanelStore.getState().forceOpenBlockMenu).toBe(true);
});
it("resets all control panel state", () => {
useControlPanelStore.getState().setBlockMenuOpen(true);
useControlPanelStore.getState().setForceOpenBlockMenu(true);
useControlPanelStore.getState().setSaveControlOpen(true);
useControlPanelStore.getState().setForceOpenSave(true);
useControlPanelStore.getState().reset();
const state = useControlPanelStore.getState();
expect(state.blockMenuOpen).toBe(false);
expect(state.forceOpenBlockMenu).toBe(false);
expect(state.saveControlOpen).toBe(false);
expect(state.forceOpenSave).toBe(false);
});
});
// ===========================================================================
// Section 3: BlockMenuContent integration tests
// ===========================================================================
// We import BlockMenuContent directly to avoid dealing with the Popover wrapper.
import { BlockMenuContent } from "../components/NewControlPanel/NewBlockMenu/BlockMenuContent/BlockMenuContent";
describe("BlockMenuContent", () => {
it("shows BlockMenuDefault when there is no search query", () => {
useBlockMenuStore.getState().setSearchQuery("");
render(<BlockMenuContent />);
expect(screen.getByTestId("block-menu-default")).toBeDefined();
expect(screen.queryByTestId("block-menu-search")).toBeNull();
});
it("shows BlockMenuSearch when a search query is present", () => {
useBlockMenuStore.getState().setSearchQuery("timer");
render(<BlockMenuContent />);
expect(screen.getByTestId("block-menu-search")).toBeDefined();
expect(screen.queryByTestId("block-menu-default")).toBeNull();
});
it("renders the search bar", () => {
render(<BlockMenuContent />);
expect(
screen.getByPlaceholderText(
"Blocks, Agents, Integrations or Keywords...",
),
).toBeDefined();
});
it("switches from default to search view when store query changes", () => {
const { rerender } = render(<BlockMenuContent />);
expect(screen.getByTestId("block-menu-default")).toBeDefined();
// Simulate typing by setting the store directly
useBlockMenuStore.getState().setSearchQuery("webhook");
rerender(<BlockMenuContent />);
expect(screen.getByTestId("block-menu-search")).toBeDefined();
expect(screen.queryByTestId("block-menu-default")).toBeNull();
});
it("switches back to default view when search query is cleared", () => {
useBlockMenuStore.getState().setSearchQuery("something");
const { rerender } = render(<BlockMenuContent />);
expect(screen.getByTestId("block-menu-search")).toBeDefined();
useBlockMenuStore.getState().setSearchQuery("");
rerender(<BlockMenuContent />);
expect(screen.getByTestId("block-menu-default")).toBeDefined();
expect(screen.queryByTestId("block-menu-search")).toBeNull();
});
it("typing in the search bar updates the local input value", async () => {
render(<BlockMenuContent />);
const input = screen.getByPlaceholderText(
"Blocks, Agents, Integrations or Keywords...",
);
fireEvent.change(input, { target: { value: "slack" } });
expect((input as HTMLInputElement).value).toBe("slack");
});
it("shows clear button when input has text and clears on click", async () => {
render(<BlockMenuContent />);
const input = screen.getByPlaceholderText(
"Blocks, Agents, Integrations or Keywords...",
);
fireEvent.change(input, { target: { value: "test" } });
// The clear button should appear
const clearButton = screen.getByRole("button");
fireEvent.click(clearButton);
await waitFor(() => {
expect((input as HTMLInputElement).value).toBe("");
});
});
});

View File

@@ -1,270 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
fireEvent,
waitFor,
cleanup,
} from "@/tests/integrations/test-utils";
import { UseFormReturn, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { renderHook } from "@testing-library/react";
import { useControlPanelStore } from "../stores/controlPanelStore";
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
import { NewSaveControl } from "../components/NewControlPanel/NewSaveControl/NewSaveControl";
import { useNewSaveControl } from "../components/NewControlPanel/NewSaveControl/useNewSaveControl";
const formSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
description: z.string().max(500),
});
type SaveableGraphFormValues = z.infer<typeof formSchema>;
const mockHandleSave = vi.fn();
vi.mock(
"../components/NewControlPanel/NewSaveControl/useNewSaveControl",
() => ({
useNewSaveControl: vi.fn(),
}),
);
const mockUseNewSaveControl = vi.mocked(useNewSaveControl);
function createMockForm(
defaults: SaveableGraphFormValues = { name: "", description: "" },
): UseFormReturn<SaveableGraphFormValues> {
const { result } = renderHook(() =>
useForm<SaveableGraphFormValues>({
resolver: zodResolver(formSchema),
defaultValues: defaults,
}),
);
return result.current;
}
function setupMock(overrides: {
isSaving?: boolean;
graphVersion?: number;
name?: string;
description?: string;
}) {
const form = createMockForm({
name: overrides.name ?? "",
description: overrides.description ?? "",
});
mockUseNewSaveControl.mockReturnValue({
form,
isSaving: overrides.isSaving ?? false,
graphVersion: overrides.graphVersion,
handleSave: mockHandleSave,
});
return form;
}
function resetStore() {
useControlPanelStore.setState({
blockMenuOpen: false,
saveControlOpen: false,
forceOpenBlockMenu: false,
forceOpenSave: false,
});
}
beforeEach(() => {
cleanup();
resetStore();
mockHandleSave.mockReset();
});
afterEach(() => {
cleanup();
});
describe("NewSaveControl", () => {
it("renders save button trigger", () => {
setupMock({});
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
expect(screen.getByTestId("save-control-save-button")).toBeDefined();
});
it("renders name and description inputs when popover is open", () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({});
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
expect(screen.getByTestId("save-control-name-input")).toBeDefined();
expect(screen.getByTestId("save-control-description-input")).toBeDefined();
});
it("does not render popover content when closed", () => {
useControlPanelStore.setState({ saveControlOpen: false });
setupMock({});
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
expect(screen.queryByTestId("save-control-name-input")).toBeNull();
expect(screen.queryByTestId("save-control-description-input")).toBeNull();
});
it("shows version output when graphVersion is set", () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({ graphVersion: 3 });
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
const versionInput = screen.getByTestId("save-control-version-output");
expect(versionInput).toBeDefined();
expect((versionInput as HTMLInputElement).disabled).toBe(true);
});
it("hides version output when graphVersion is undefined", () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({ graphVersion: undefined });
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
expect(screen.queryByTestId("save-control-version-output")).toBeNull();
});
it("enables save button when isSaving is false", () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({ isSaving: false });
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
const saveButton = screen.getByTestId("save-control-save-agent-button");
expect((saveButton as HTMLButtonElement).disabled).toBe(false);
});
it("disables save button when isSaving is true", () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({ isSaving: true });
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
const saveButton = screen.getByRole("button", { name: /save agent/i });
expect((saveButton as HTMLButtonElement).disabled).toBe(true);
});
it("calls handleSave on form submission with valid data", async () => {
useControlPanelStore.setState({ saveControlOpen: true });
const form = setupMock({ name: "My Agent", description: "A description" });
form.setValue("name", "My Agent");
form.setValue("description", "A description");
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
const saveButton = screen.getByTestId("save-control-save-agent-button");
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockHandleSave).toHaveBeenCalledWith(
{ name: "My Agent", description: "A description" },
expect.anything(),
);
});
});
it("does not call handleSave when name is empty (validation fails)", async () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({ name: "", description: "" });
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
const saveButton = screen.getByTestId("save-control-save-agent-button");
fireEvent.click(saveButton);
await waitFor(() => {
expect(mockHandleSave).not.toHaveBeenCalled();
});
});
it("popover stays open when forceOpenSave is true", () => {
useControlPanelStore.setState({
saveControlOpen: false,
forceOpenSave: true,
});
setupMock({});
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
expect(screen.getByTestId("save-control-name-input")).toBeDefined();
});
it("allows typing in name and description inputs", () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({});
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
const nameInput = screen.getByTestId(
"save-control-name-input",
) as HTMLInputElement;
const descriptionInput = screen.getByTestId(
"save-control-description-input",
) as HTMLInputElement;
fireEvent.change(nameInput, { target: { value: "Test Agent" } });
fireEvent.change(descriptionInput, {
target: { value: "Test Description" },
});
expect(nameInput.value).toBe("Test Agent");
expect(descriptionInput.value).toBe("Test Description");
});
it("displays save button text", () => {
useControlPanelStore.setState({ saveControlOpen: true });
setupMock({});
render(
<TooltipProvider>
<NewSaveControl />
</TooltipProvider>,
);
expect(screen.getByText("Save Agent")).toBeDefined();
});
});

View File

@@ -1,147 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { screen, fireEvent, cleanup } from "@testing-library/react";
import { render } from "@/tests/integrations/test-utils";
import React from "react";
import { useGraphStore } from "../stores/graphStore";
vi.mock(
"@/app/(platform)/build/components/BuilderActions/components/RunGraph/useRunGraph",
() => ({
useRunGraph: vi.fn(),
}),
);
vi.mock(
"@/app/(platform)/build/components/BuilderActions/components/RunInputDialog/RunInputDialog",
() => ({
RunInputDialog: ({ isOpen }: { isOpen: boolean }) =>
isOpen ? <div data-testid="run-input-dialog">Dialog</div> : null,
}),
);
// Must import after mocks
import { useRunGraph } from "../components/BuilderActions/components/RunGraph/useRunGraph";
import { RunGraph } from "../components/BuilderActions/components/RunGraph/RunGraph";
const mockUseRunGraph = vi.mocked(useRunGraph);
function createMockReturnValue(
overrides: Partial<ReturnType<typeof useRunGraph>> = {},
) {
return {
handleRunGraph: vi.fn(),
handleStopGraph: vi.fn(),
openRunInputDialog: false,
setOpenRunInputDialog: vi.fn(),
isExecutingGraph: false,
isTerminatingGraph: false,
isSaving: false,
...overrides,
};
}
// RunGraph uses Tooltip which requires TooltipProvider
import { TooltipProvider } from "@/components/atoms/Tooltip/BaseTooltip";
function renderRunGraph(flowID: string | null = "test-flow-id") {
return render(
<TooltipProvider>
<RunGraph flowID={flowID} />
</TooltipProvider>,
);
}
describe("RunGraph", () => {
beforeEach(() => {
cleanup();
mockUseRunGraph.mockReturnValue(createMockReturnValue());
useGraphStore.setState({ isGraphRunning: false });
});
afterEach(() => {
cleanup();
});
it("renders an enabled button when flowID is provided", () => {
renderRunGraph("test-flow-id");
const button = screen.getByRole("button");
expect((button as HTMLButtonElement).disabled).toBe(false);
});
it("renders a disabled button when flowID is null", () => {
renderRunGraph(null);
const button = screen.getByRole("button");
expect((button as HTMLButtonElement).disabled).toBe(true);
});
it("disables the button when isExecutingGraph is true", () => {
mockUseRunGraph.mockReturnValue(
createMockReturnValue({ isExecutingGraph: true }),
);
renderRunGraph();
expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(
true,
);
});
it("disables the button when isTerminatingGraph is true", () => {
mockUseRunGraph.mockReturnValue(
createMockReturnValue({ isTerminatingGraph: true }),
);
renderRunGraph();
expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(
true,
);
});
it("disables the button when isSaving is true", () => {
mockUseRunGraph.mockReturnValue(createMockReturnValue({ isSaving: true }));
renderRunGraph();
expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(
true,
);
});
it("uses data-id run-graph-button when not running", () => {
renderRunGraph();
const button = screen.getByRole("button");
expect(button.getAttribute("data-id")).toBe("run-graph-button");
});
it("uses data-id stop-graph-button when running", () => {
useGraphStore.setState({ isGraphRunning: true });
renderRunGraph();
const button = screen.getByRole("button");
expect(button.getAttribute("data-id")).toBe("stop-graph-button");
});
it("calls handleRunGraph when clicked and graph is not running", () => {
const handleRunGraph = vi.fn();
mockUseRunGraph.mockReturnValue(createMockReturnValue({ handleRunGraph }));
renderRunGraph();
fireEvent.click(screen.getByRole("button"));
expect(handleRunGraph).toHaveBeenCalledOnce();
});
it("calls handleStopGraph when clicked and graph is running", () => {
const handleStopGraph = vi.fn();
mockUseRunGraph.mockReturnValue(createMockReturnValue({ handleStopGraph }));
useGraphStore.setState({ isGraphRunning: true });
renderRunGraph();
fireEvent.click(screen.getByRole("button"));
expect(handleStopGraph).toHaveBeenCalledOnce();
});
it("renders RunInputDialog hidden by default", () => {
renderRunGraph();
expect(screen.queryByTestId("run-input-dialog")).toBeNull();
});
it("renders RunInputDialog when openRunInputDialog is true", () => {
mockUseRunGraph.mockReturnValue(
createMockReturnValue({ openRunInputDialog: true }),
);
renderRunGraph();
expect(screen.getByTestId("run-input-dialog")).toBeDefined();
});
});

View File

@@ -1,257 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { BlockUIType } from "../components/types";
vi.mock("@/services/storage/local-storage", () => {
const store: Record<string, string> = {};
return {
Key: { COPIED_FLOW_DATA: "COPIED_FLOW_DATA" },
storage: {
get: (key: string) => store[key] ?? null,
set: (key: string, value: string) => {
store[key] = value;
},
clean: (key: string) => {
delete store[key];
},
},
};
});
import { useCopyPasteStore } from "../stores/copyPasteStore";
import { useNodeStore } from "../stores/nodeStore";
import { useEdgeStore } from "../stores/edgeStore";
import { useHistoryStore } from "../stores/historyStore";
import { storage, Key } from "@/services/storage/local-storage";
function createTestNode(
id: string,
overrides: Partial<CustomNode> = {},
): CustomNode {
return {
id,
type: "custom",
position: overrides.position ?? { x: 100, y: 200 },
selected: overrides.selected,
data: {
hardcodedValues: {},
title: `Node ${id}`,
description: "test node",
inputSchema: {},
outputSchema: {},
uiType: BlockUIType.STANDARD,
block_id: `block-${id}`,
costs: [],
categories: [],
...overrides.data,
},
} as CustomNode;
}
describe("useCopyPasteStore", () => {
beforeEach(() => {
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
useEdgeStore.setState({ edges: [] });
useHistoryStore.getState().clear();
storage.clean(Key.COPIED_FLOW_DATA);
});
describe("copySelectedNodes", () => {
it("copies a single selected node to localStorage", () => {
const node = createTestNode("1", { selected: true });
useNodeStore.setState({ nodes: [node] });
useCopyPasteStore.getState().copySelectedNodes();
const stored = storage.get(Key.COPIED_FLOW_DATA);
expect(stored).not.toBeNull();
const parsed = JSON.parse(stored!);
expect(parsed.nodes).toHaveLength(1);
expect(parsed.nodes[0].id).toBe("1");
expect(parsed.edges).toHaveLength(0);
});
it("copies only edges between selected nodes", () => {
const nodeA = createTestNode("a", { selected: true });
const nodeB = createTestNode("b", { selected: true });
const nodeC = createTestNode("c", { selected: false });
useNodeStore.setState({ nodes: [nodeA, nodeB, nodeC] });
useEdgeStore.setState({
edges: [
{
id: "e-ab",
source: "a",
target: "b",
sourceHandle: "out",
targetHandle: "in",
},
{
id: "e-bc",
source: "b",
target: "c",
sourceHandle: "out",
targetHandle: "in",
},
{
id: "e-ac",
source: "a",
target: "c",
sourceHandle: "out",
targetHandle: "in",
},
],
});
useCopyPasteStore.getState().copySelectedNodes();
const parsed = JSON.parse(storage.get(Key.COPIED_FLOW_DATA)!);
expect(parsed.nodes).toHaveLength(2);
expect(parsed.edges).toHaveLength(1);
expect(parsed.edges[0].id).toBe("e-ab");
});
it("stores empty data when no nodes are selected", () => {
const node = createTestNode("1", { selected: false });
useNodeStore.setState({ nodes: [node] });
useCopyPasteStore.getState().copySelectedNodes();
const parsed = JSON.parse(storage.get(Key.COPIED_FLOW_DATA)!);
expect(parsed.nodes).toHaveLength(0);
expect(parsed.edges).toHaveLength(0);
});
});
describe("pasteNodes", () => {
it("creates new nodes with new IDs via incrementNodeCounter", () => {
const node = createTestNode("orig", {
selected: true,
position: { x: 100, y: 200 },
});
useNodeStore.setState({ nodes: [node], nodeCounter: 5 });
useCopyPasteStore.getState().copySelectedNodes();
useCopyPasteStore.getState().pasteNodes();
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(2);
const pastedNode = nodes.find((n) => n.id !== "orig");
expect(pastedNode).toBeDefined();
expect(pastedNode!.id).not.toBe("orig");
});
it("offsets pasted node positions by +50 x/y", () => {
const node = createTestNode("orig", {
selected: true,
position: { x: 100, y: 200 },
});
useNodeStore.setState({ nodes: [node], nodeCounter: 5 });
useCopyPasteStore.getState().copySelectedNodes();
useCopyPasteStore.getState().pasteNodes();
const { nodes } = useNodeStore.getState();
const pastedNode = nodes.find((n) => n.id !== "orig");
expect(pastedNode).toBeDefined();
expect(pastedNode!.position).toEqual({ x: 150, y: 250 });
});
it("preserves internal connections with remapped IDs", () => {
const nodeA = createTestNode("a", {
selected: true,
position: { x: 0, y: 0 },
});
const nodeB = createTestNode("b", {
selected: true,
position: { x: 200, y: 0 },
});
useNodeStore.setState({ nodes: [nodeA, nodeB], nodeCounter: 0 });
useEdgeStore.setState({
edges: [
{
id: "e-ab",
source: "a",
target: "b",
sourceHandle: "output",
targetHandle: "input",
},
],
});
useCopyPasteStore.getState().copySelectedNodes();
useCopyPasteStore.getState().pasteNodes();
const { edges } = useEdgeStore.getState();
const newEdges = edges.filter((e) => e.id !== "e-ab");
expect(newEdges).toHaveLength(1);
const newEdge = newEdges[0];
expect(newEdge.source).not.toBe("a");
expect(newEdge.target).not.toBe("b");
const { nodes } = useNodeStore.getState();
const pastedNodeIDs = nodes
.filter((n) => n.id !== "a" && n.id !== "b")
.map((n) => n.id);
expect(pastedNodeIDs).toContain(newEdge.source);
expect(pastedNodeIDs).toContain(newEdge.target);
});
it("deselects existing nodes and selects pasted ones", () => {
const existingNode = createTestNode("existing", {
selected: true,
position: { x: 0, y: 0 },
});
const nodeToCopy = createTestNode("copy-me", {
selected: true,
position: { x: 100, y: 100 },
});
useNodeStore.setState({
nodes: [existingNode, nodeToCopy],
nodeCounter: 0,
});
useCopyPasteStore.getState().copySelectedNodes();
// Deselect nodeToCopy, keep existingNode selected to verify deselection on paste
useNodeStore.setState({
nodes: [
{ ...existingNode, selected: true },
{ ...nodeToCopy, selected: false },
],
});
useCopyPasteStore.getState().pasteNodes();
const { nodes } = useNodeStore.getState();
const originalNodes = nodes.filter(
(n) => n.id === "existing" || n.id === "copy-me",
);
const pastedNodes = nodes.filter(
(n) => n.id !== "existing" && n.id !== "copy-me",
);
originalNodes.forEach((n) => {
expect(n.selected).toBe(false);
});
pastedNodes.forEach((n) => {
expect(n.selected).toBe(true);
});
});
it("does nothing when clipboard is empty", () => {
const node = createTestNode("1", { position: { x: 0, y: 0 } });
useNodeStore.setState({ nodes: [node], nodeCounter: 0 });
useCopyPasteStore.getState().pasteNodes();
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe("1");
});
});
});

View File

@@ -1,751 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { MarkerType } from "@xyflow/react";
import { useEdgeStore } from "../stores/edgeStore";
import { useNodeStore } from "../stores/nodeStore";
import { useHistoryStore } from "../stores/historyStore";
import type { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
import type { Link } from "@/app/api/__generated__/models/link";
function makeEdge(overrides: Partial<CustomEdge> & { id: string }): CustomEdge {
return {
type: "custom",
source: "node-a",
target: "node-b",
sourceHandle: "output",
targetHandle: "input",
...overrides,
};
}
function makeExecutionResult(
overrides: Partial<NodeExecutionResult>,
): NodeExecutionResult {
return {
user_id: "user-1",
graph_id: "graph-1",
graph_version: 1,
graph_exec_id: "gexec-1",
node_exec_id: "nexec-1",
node_id: "node-1",
block_id: "block-1",
status: "INCOMPLETE",
input_data: {},
output_data: {},
add_time: new Date(),
queue_time: null,
start_time: null,
end_time: null,
...overrides,
};
}
beforeEach(() => {
useEdgeStore.setState({ edges: [] });
useNodeStore.setState({ nodes: [] });
useHistoryStore.setState({ past: [], future: [] });
});
describe("edgeStore", () => {
describe("setEdges", () => {
it("replaces all edges", () => {
const edges = [
makeEdge({ id: "e1" }),
makeEdge({ id: "e2", source: "node-c" }),
];
useEdgeStore.getState().setEdges(edges);
expect(useEdgeStore.getState().edges).toHaveLength(2);
expect(useEdgeStore.getState().edges[0].id).toBe("e1");
expect(useEdgeStore.getState().edges[1].id).toBe("e2");
});
});
describe("addEdge", () => {
it("adds an edge and auto-generates an ID", () => {
const result = useEdgeStore.getState().addEdge({
source: "n1",
target: "n2",
sourceHandle: "out",
targetHandle: "in",
});
expect(result.id).toBe("n1:out->n2:in");
expect(useEdgeStore.getState().edges).toHaveLength(1);
expect(useEdgeStore.getState().edges[0].id).toBe("n1:out->n2:in");
});
it("uses provided ID when given", () => {
const result = useEdgeStore.getState().addEdge({
id: "custom-id",
source: "n1",
target: "n2",
sourceHandle: "out",
targetHandle: "in",
});
expect(result.id).toBe("custom-id");
});
it("sets type to custom and adds arrow marker", () => {
const result = useEdgeStore.getState().addEdge({
source: "n1",
target: "n2",
sourceHandle: "out",
targetHandle: "in",
});
expect(result.type).toBe("custom");
expect(result.markerEnd).toEqual({
type: MarkerType.ArrowClosed,
strokeWidth: 2,
color: "#555",
});
});
it("rejects duplicate edges without adding", () => {
useEdgeStore.getState().addEdge({
source: "n1",
target: "n2",
sourceHandle: "out",
targetHandle: "in",
});
const pushSpy = vi.spyOn(useHistoryStore.getState(), "pushState");
const duplicate = useEdgeStore.getState().addEdge({
source: "n1",
target: "n2",
sourceHandle: "out",
targetHandle: "in",
});
expect(useEdgeStore.getState().edges).toHaveLength(1);
expect(duplicate.id).toBe("n1:out->n2:in");
expect(pushSpy).not.toHaveBeenCalled();
pushSpy.mockRestore();
});
it("pushes previous state to history store", () => {
const pushSpy = vi.spyOn(useHistoryStore.getState(), "pushState");
useEdgeStore.getState().addEdge({
source: "n1",
target: "n2",
sourceHandle: "out",
targetHandle: "in",
});
expect(pushSpy).toHaveBeenCalledWith({
nodes: [],
edges: [],
});
pushSpy.mockRestore();
});
});
describe("removeEdge", () => {
it("removes an edge by ID", () => {
useEdgeStore.setState({
edges: [makeEdge({ id: "e1" }), makeEdge({ id: "e2" })],
});
useEdgeStore.getState().removeEdge("e1");
expect(useEdgeStore.getState().edges).toHaveLength(1);
expect(useEdgeStore.getState().edges[0].id).toBe("e2");
});
it("does nothing when removing a non-existent edge", () => {
useEdgeStore.setState({ edges: [makeEdge({ id: "e1" })] });
useEdgeStore.getState().removeEdge("nonexistent");
expect(useEdgeStore.getState().edges).toHaveLength(1);
});
it("pushes previous state to history store", () => {
const existingEdges = [makeEdge({ id: "e1" })];
useEdgeStore.setState({ edges: existingEdges });
const pushSpy = vi.spyOn(useHistoryStore.getState(), "pushState");
useEdgeStore.getState().removeEdge("e1");
expect(pushSpy).toHaveBeenCalledWith({
nodes: [],
edges: existingEdges,
});
pushSpy.mockRestore();
});
});
describe("upsertMany", () => {
it("inserts new edges", () => {
useEdgeStore.setState({ edges: [makeEdge({ id: "e1" })] });
useEdgeStore.getState().upsertMany([makeEdge({ id: "e2" })]);
expect(useEdgeStore.getState().edges).toHaveLength(2);
});
it("updates existing edges by ID", () => {
useEdgeStore.setState({
edges: [makeEdge({ id: "e1", source: "old-source" })],
});
useEdgeStore
.getState()
.upsertMany([makeEdge({ id: "e1", source: "new-source" })]);
expect(useEdgeStore.getState().edges).toHaveLength(1);
expect(useEdgeStore.getState().edges[0].source).toBe("new-source");
});
it("handles mixed inserts and updates", () => {
useEdgeStore.setState({
edges: [makeEdge({ id: "e1", source: "old" })],
});
useEdgeStore
.getState()
.upsertMany([
makeEdge({ id: "e1", source: "updated" }),
makeEdge({ id: "e2", source: "new" }),
]);
const edges = useEdgeStore.getState().edges;
expect(edges).toHaveLength(2);
expect(edges.find((e) => e.id === "e1")?.source).toBe("updated");
expect(edges.find((e) => e.id === "e2")?.source).toBe("new");
});
});
describe("removeEdgesByHandlePrefix", () => {
it("removes edges targeting a node with matching handle prefix", () => {
useEdgeStore.setState({
edges: [
makeEdge({ id: "e1", target: "node-b", targetHandle: "input_foo" }),
makeEdge({ id: "e2", target: "node-b", targetHandle: "input_bar" }),
makeEdge({
id: "e3",
target: "node-b",
targetHandle: "other_handle",
}),
makeEdge({ id: "e4", target: "node-c", targetHandle: "input_foo" }),
],
});
useEdgeStore.getState().removeEdgesByHandlePrefix("node-b", "input_");
const edges = useEdgeStore.getState().edges;
expect(edges).toHaveLength(2);
expect(edges.map((e) => e.id).sort()).toEqual(["e3", "e4"]);
});
it("does not remove edges where target does not match nodeId", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
source: "node-b",
target: "node-c",
targetHandle: "input_x",
}),
],
});
useEdgeStore.getState().removeEdgesByHandlePrefix("node-b", "input_");
expect(useEdgeStore.getState().edges).toHaveLength(1);
});
});
describe("getNodeEdges", () => {
it("returns edges where node is source", () => {
useEdgeStore.setState({
edges: [
makeEdge({ id: "e1", source: "node-a", target: "node-b" }),
makeEdge({ id: "e2", source: "node-c", target: "node-d" }),
],
});
const result = useEdgeStore.getState().getNodeEdges("node-a");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("e1");
});
it("returns edges where node is target", () => {
useEdgeStore.setState({
edges: [
makeEdge({ id: "e1", source: "node-a", target: "node-b" }),
makeEdge({ id: "e2", source: "node-c", target: "node-d" }),
],
});
const result = useEdgeStore.getState().getNodeEdges("node-b");
expect(result).toHaveLength(1);
expect(result[0].id).toBe("e1");
});
it("returns edges for both source and target", () => {
useEdgeStore.setState({
edges: [
makeEdge({ id: "e1", source: "node-a", target: "node-b" }),
makeEdge({ id: "e2", source: "node-b", target: "node-c" }),
makeEdge({ id: "e3", source: "node-d", target: "node-e" }),
],
});
const result = useEdgeStore.getState().getNodeEdges("node-b");
expect(result).toHaveLength(2);
expect(result.map((e) => e.id).sort()).toEqual(["e1", "e2"]);
});
it("returns empty array for unconnected node", () => {
useEdgeStore.setState({
edges: [makeEdge({ id: "e1", source: "node-a", target: "node-b" })],
});
expect(useEdgeStore.getState().getNodeEdges("node-z")).toHaveLength(0);
});
});
describe("isInputConnected", () => {
it("returns true when target handle is connected", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: "input",
}),
],
});
expect(useEdgeStore.getState().isInputConnected("node-b", "input")).toBe(
true,
);
});
it("returns false when target handle is not connected", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: "input",
}),
],
});
expect(useEdgeStore.getState().isInputConnected("node-b", "other")).toBe(
false,
);
});
it("returns false when node is source not target", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
source: "node-b",
target: "node-c",
sourceHandle: "output",
targetHandle: "input",
}),
],
});
expect(useEdgeStore.getState().isInputConnected("node-b", "output")).toBe(
false,
);
});
});
describe("isOutputConnected", () => {
it("returns true when source handle is connected", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
source: "node-a",
sourceHandle: "output",
}),
],
});
expect(
useEdgeStore.getState().isOutputConnected("node-a", "output"),
).toBe(true);
});
it("returns false when source handle is not connected", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
source: "node-a",
sourceHandle: "output",
}),
],
});
expect(useEdgeStore.getState().isOutputConnected("node-a", "other")).toBe(
false,
);
});
});
describe("getBackendLinks", () => {
it("converts edges to Link format", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
source: "n1",
target: "n2",
sourceHandle: "out",
targetHandle: "in",
data: { isStatic: true },
}),
],
});
const links = useEdgeStore.getState().getBackendLinks();
expect(links).toHaveLength(1);
expect(links[0]).toEqual({
id: "e1",
source_id: "n1",
sink_id: "n2",
source_name: "out",
sink_name: "in",
is_static: true,
});
});
});
describe("addLinks", () => {
it("converts Links to edges and adds them", () => {
const links: Link[] = [
{
id: "link-1",
source_id: "n1",
sink_id: "n2",
source_name: "out",
sink_name: "in",
is_static: false,
},
];
useEdgeStore.getState().addLinks(links);
const edges = useEdgeStore.getState().edges;
expect(edges).toHaveLength(1);
expect(edges[0].source).toBe("n1");
expect(edges[0].target).toBe("n2");
expect(edges[0].sourceHandle).toBe("out");
expect(edges[0].targetHandle).toBe("in");
expect(edges[0].data?.isStatic).toBe(false);
});
it("adds multiple links", () => {
const links: Link[] = [
{
id: "link-1",
source_id: "n1",
sink_id: "n2",
source_name: "out",
sink_name: "in",
},
{
id: "link-2",
source_id: "n3",
sink_id: "n4",
source_name: "result",
sink_name: "value",
},
];
useEdgeStore.getState().addLinks(links);
expect(useEdgeStore.getState().edges).toHaveLength(2);
});
});
describe("getAllHandleIdsOfANode", () => {
it("returns targetHandle values for edges targeting the node", () => {
useEdgeStore.setState({
edges: [
makeEdge({ id: "e1", target: "node-b", targetHandle: "input_a" }),
makeEdge({ id: "e2", target: "node-b", targetHandle: "input_b" }),
makeEdge({ id: "e3", target: "node-c", targetHandle: "input_c" }),
],
});
const handles = useEdgeStore.getState().getAllHandleIdsOfANode("node-b");
expect(handles).toEqual(["input_a", "input_b"]);
});
it("returns empty array when no edges target the node", () => {
useEdgeStore.setState({
edges: [makeEdge({ id: "e1", source: "node-b", target: "node-c" })],
});
expect(useEdgeStore.getState().getAllHandleIdsOfANode("node-b")).toEqual(
[],
);
});
it("returns empty string for edges with no targetHandle", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: undefined,
}),
],
});
expect(useEdgeStore.getState().getAllHandleIdsOfANode("node-b")).toEqual([
"",
]);
});
});
describe("updateEdgeBeads", () => {
it("updates bead counts for edges targeting the node", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: "input",
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
}),
],
});
useEdgeStore.getState().updateEdgeBeads(
"node-b",
makeExecutionResult({
node_exec_id: "exec-1",
status: "COMPLETED",
input_data: { input: "some-value" },
}),
);
const edge = useEdgeStore.getState().edges[0];
expect(edge.data?.beadUp).toBe(1);
expect(edge.data?.beadDown).toBe(1);
});
it("counts INCOMPLETE status in beadUp but not beadDown", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: "input",
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
}),
],
});
useEdgeStore.getState().updateEdgeBeads(
"node-b",
makeExecutionResult({
node_exec_id: "exec-1",
status: "INCOMPLETE",
input_data: { input: "data" },
}),
);
const edge = useEdgeStore.getState().edges[0];
expect(edge.data?.beadUp).toBe(1);
expect(edge.data?.beadDown).toBe(0);
});
it("does not modify edges not targeting the node", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-c",
targetHandle: "input",
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
}),
],
});
useEdgeStore.getState().updateEdgeBeads(
"node-b",
makeExecutionResult({
node_exec_id: "exec-1",
status: "COMPLETED",
input_data: { input: "data" },
}),
);
const edge = useEdgeStore.getState().edges[0];
expect(edge.data?.beadUp).toBe(0);
expect(edge.data?.beadDown).toBe(0);
});
it("does not update edge when input_data has no matching handle", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: "input",
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
}),
],
});
useEdgeStore.getState().updateEdgeBeads(
"node-b",
makeExecutionResult({
node_exec_id: "exec-1",
status: "COMPLETED",
input_data: { other_handle: "data" },
}),
);
const edge = useEdgeStore.getState().edges[0];
expect(edge.data?.beadUp).toBe(0);
expect(edge.data?.beadDown).toBe(0);
});
it("accumulates beads across multiple executions", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: "input",
data: { beadUp: 0, beadDown: 0, beadData: new Map() },
}),
],
});
useEdgeStore.getState().updateEdgeBeads(
"node-b",
makeExecutionResult({
node_exec_id: "exec-1",
status: "COMPLETED",
input_data: { input: "data1" },
}),
);
useEdgeStore.getState().updateEdgeBeads(
"node-b",
makeExecutionResult({
node_exec_id: "exec-2",
status: "INCOMPLETE",
input_data: { input: "data2" },
}),
);
const edge = useEdgeStore.getState().edges[0];
expect(edge.data?.beadUp).toBe(2);
expect(edge.data?.beadDown).toBe(1);
});
it("handles static edges by setting beadUp to beadDown + 1", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
target: "node-b",
targetHandle: "input",
data: {
isStatic: true,
beadUp: 0,
beadDown: 0,
beadData: new Map(),
},
}),
],
});
useEdgeStore.getState().updateEdgeBeads(
"node-b",
makeExecutionResult({
node_exec_id: "exec-1",
status: "COMPLETED",
input_data: { input: "data" },
}),
);
const edge = useEdgeStore.getState().edges[0];
expect(edge.data?.beadUp).toBe(2);
expect(edge.data?.beadDown).toBe(1);
});
});
describe("resetEdgeBeads", () => {
it("resets all bead data on all edges", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
data: {
beadUp: 5,
beadDown: 3,
beadData: new Map([["exec-1", "COMPLETED"]]),
},
}),
makeEdge({
id: "e2",
data: {
beadUp: 2,
beadDown: 1,
beadData: new Map([["exec-2", "INCOMPLETE"]]),
},
}),
],
});
useEdgeStore.getState().resetEdgeBeads();
const edges = useEdgeStore.getState().edges;
for (const edge of edges) {
expect(edge.data?.beadUp).toBe(0);
expect(edge.data?.beadDown).toBe(0);
expect(edge.data?.beadData?.size).toBe(0);
}
});
it("preserves other edge data when resetting beads", () => {
useEdgeStore.setState({
edges: [
makeEdge({
id: "e1",
data: {
isStatic: true,
edgeColorClass: "text-red-500",
beadUp: 3,
beadDown: 2,
beadData: new Map(),
},
}),
],
});
useEdgeStore.getState().resetEdgeBeads();
const edge = useEdgeStore.getState().edges[0];
expect(edge.data?.isStatic).toBe(true);
expect(edge.data?.edgeColorClass).toBe("text-red-500");
expect(edge.data?.beadUp).toBe(0);
});
});
});

View File

@@ -1,347 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useGraphStore } from "../stores/graphStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { GraphMeta } from "@/app/api/__generated__/models/graphMeta";
function createTestGraphMeta(
overrides: Partial<GraphMeta> & { id: string; name: string },
): GraphMeta {
return {
version: 1,
description: "",
is_active: true,
user_id: "test-user",
created_at: new Date("2024-01-01T00:00:00Z"),
...overrides,
};
}
function resetStore() {
useGraphStore.setState({
graphExecutionStatus: undefined,
isGraphRunning: false,
inputSchema: null,
credentialsInputSchema: null,
outputSchema: null,
availableSubGraphs: [],
});
}
beforeEach(() => {
resetStore();
});
describe("graphStore", () => {
describe("execution status transitions", () => {
it("handles QUEUED -> RUNNING -> COMPLETED transition", () => {
const { setGraphExecutionStatus } = useGraphStore.getState();
setGraphExecutionStatus(AgentExecutionStatus.QUEUED);
expect(useGraphStore.getState().graphExecutionStatus).toBe(
AgentExecutionStatus.QUEUED,
);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
expect(useGraphStore.getState().graphExecutionStatus).toBe(
AgentExecutionStatus.RUNNING,
);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
setGraphExecutionStatus(AgentExecutionStatus.COMPLETED);
expect(useGraphStore.getState().graphExecutionStatus).toBe(
AgentExecutionStatus.COMPLETED,
);
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
it("handles QUEUED -> RUNNING -> FAILED transition", () => {
const { setGraphExecutionStatus } = useGraphStore.getState();
setGraphExecutionStatus(AgentExecutionStatus.QUEUED);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
setGraphExecutionStatus(AgentExecutionStatus.FAILED);
expect(useGraphStore.getState().graphExecutionStatus).toBe(
AgentExecutionStatus.FAILED,
);
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
});
describe("setGraphExecutionStatus auto-sets isGraphRunning", () => {
it("sets isGraphRunning to true for RUNNING", () => {
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
});
it("sets isGraphRunning to true for QUEUED", () => {
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.QUEUED);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
});
it("sets isGraphRunning to false for COMPLETED", () => {
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.COMPLETED);
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
it("sets isGraphRunning to false for FAILED", () => {
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.FAILED);
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
it("sets isGraphRunning to false for TERMINATED", () => {
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.TERMINATED);
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
it("sets isGraphRunning to false for INCOMPLETE", () => {
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.INCOMPLETE);
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
it("sets isGraphRunning to false for undefined", () => {
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
useGraphStore.getState().setGraphExecutionStatus(undefined);
expect(useGraphStore.getState().graphExecutionStatus).toBeUndefined();
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
});
describe("setIsGraphRunning", () => {
it("sets isGraphRunning independently of status", () => {
useGraphStore.getState().setIsGraphRunning(true);
expect(useGraphStore.getState().isGraphRunning).toBe(true);
useGraphStore.getState().setIsGraphRunning(false);
expect(useGraphStore.getState().isGraphRunning).toBe(false);
});
});
describe("schema management", () => {
it("sets all three schemas via setGraphSchemas", () => {
const input = { properties: { prompt: { type: "string" } } };
const credentials = { properties: { apiKey: { type: "string" } } };
const output = { properties: { result: { type: "string" } } };
useGraphStore.getState().setGraphSchemas(input, credentials, output);
const state = useGraphStore.getState();
expect(state.inputSchema).toEqual(input);
expect(state.credentialsInputSchema).toEqual(credentials);
expect(state.outputSchema).toEqual(output);
});
it("sets schemas to null", () => {
const input = { properties: { prompt: { type: "string" } } };
useGraphStore.getState().setGraphSchemas(input, null, null);
const state = useGraphStore.getState();
expect(state.inputSchema).toEqual(input);
expect(state.credentialsInputSchema).toBeNull();
expect(state.outputSchema).toBeNull();
});
it("overwrites previous schemas", () => {
const first = { properties: { a: { type: "string" } } };
const second = { properties: { b: { type: "number" } } };
useGraphStore.getState().setGraphSchemas(first, first, first);
useGraphStore.getState().setGraphSchemas(second, null, second);
const state = useGraphStore.getState();
expect(state.inputSchema).toEqual(second);
expect(state.credentialsInputSchema).toBeNull();
expect(state.outputSchema).toEqual(second);
});
});
describe("hasInputs", () => {
it("returns false when inputSchema is null", () => {
expect(useGraphStore.getState().hasInputs()).toBe(false);
});
it("returns false when inputSchema has no properties", () => {
useGraphStore.getState().setGraphSchemas({}, null, null);
expect(useGraphStore.getState().hasInputs()).toBe(false);
});
it("returns false when inputSchema has empty properties", () => {
useGraphStore.getState().setGraphSchemas({ properties: {} }, null, null);
expect(useGraphStore.getState().hasInputs()).toBe(false);
});
it("returns true when inputSchema has properties", () => {
useGraphStore
.getState()
.setGraphSchemas(
{ properties: { prompt: { type: "string" } } },
null,
null,
);
expect(useGraphStore.getState().hasInputs()).toBe(true);
});
});
describe("hasCredentials", () => {
it("returns false when credentialsInputSchema is null", () => {
expect(useGraphStore.getState().hasCredentials()).toBe(false);
});
it("returns false when credentialsInputSchema has empty properties", () => {
useGraphStore.getState().setGraphSchemas(null, { properties: {} }, null);
expect(useGraphStore.getState().hasCredentials()).toBe(false);
});
it("returns true when credentialsInputSchema has properties", () => {
useGraphStore
.getState()
.setGraphSchemas(
null,
{ properties: { apiKey: { type: "string" } } },
null,
);
expect(useGraphStore.getState().hasCredentials()).toBe(true);
});
});
describe("hasOutputs", () => {
it("returns false when outputSchema is null", () => {
expect(useGraphStore.getState().hasOutputs()).toBe(false);
});
it("returns false when outputSchema has empty properties", () => {
useGraphStore.getState().setGraphSchemas(null, null, { properties: {} });
expect(useGraphStore.getState().hasOutputs()).toBe(false);
});
it("returns true when outputSchema has properties", () => {
useGraphStore.getState().setGraphSchemas(null, null, {
properties: { result: { type: "string" } },
});
expect(useGraphStore.getState().hasOutputs()).toBe(true);
});
});
describe("reset", () => {
it("clears execution status and schemas but preserves outputSchema and availableSubGraphs", () => {
const subGraphs: GraphMeta[] = [
createTestGraphMeta({
id: "sub-1",
name: "Sub Graph",
description: "A sub graph",
}),
];
useGraphStore
.getState()
.setGraphExecutionStatus(AgentExecutionStatus.RUNNING);
useGraphStore
.getState()
.setGraphSchemas(
{ properties: { a: {} } },
{ properties: { b: {} } },
{ properties: { c: {} } },
);
useGraphStore.getState().setAvailableSubGraphs(subGraphs);
useGraphStore.getState().reset();
const state = useGraphStore.getState();
expect(state.graphExecutionStatus).toBeUndefined();
expect(state.isGraphRunning).toBe(false);
expect(state.inputSchema).toBeNull();
expect(state.credentialsInputSchema).toBeNull();
// reset does not clear outputSchema or availableSubGraphs
expect(state.outputSchema).toEqual({ properties: { c: {} } });
expect(state.availableSubGraphs).toEqual(subGraphs);
});
it("is idempotent on fresh state", () => {
useGraphStore.getState().reset();
const state = useGraphStore.getState();
expect(state.graphExecutionStatus).toBeUndefined();
expect(state.isGraphRunning).toBe(false);
expect(state.inputSchema).toBeNull();
expect(state.credentialsInputSchema).toBeNull();
});
});
describe("setAvailableSubGraphs", () => {
it("sets sub-graphs list", () => {
const graphs: GraphMeta[] = [
createTestGraphMeta({
id: "graph-1",
name: "Graph One",
description: "First graph",
}),
createTestGraphMeta({
id: "graph-2",
version: 2,
name: "Graph Two",
description: "Second graph",
}),
];
useGraphStore.getState().setAvailableSubGraphs(graphs);
expect(useGraphStore.getState().availableSubGraphs).toEqual(graphs);
});
it("replaces previous sub-graphs", () => {
const first: GraphMeta[] = [createTestGraphMeta({ id: "a", name: "A" })];
const second: GraphMeta[] = [
createTestGraphMeta({ id: "b", name: "B" }),
createTestGraphMeta({ id: "c", name: "C" }),
];
useGraphStore.getState().setAvailableSubGraphs(first);
expect(useGraphStore.getState().availableSubGraphs).toHaveLength(1);
useGraphStore.getState().setAvailableSubGraphs(second);
expect(useGraphStore.getState().availableSubGraphs).toHaveLength(2);
expect(useGraphStore.getState().availableSubGraphs).toEqual(second);
});
it("can set empty sub-graphs list", () => {
useGraphStore
.getState()
.setAvailableSubGraphs([createTestGraphMeta({ id: "x", name: "X" })]);
useGraphStore.getState().setAvailableSubGraphs([]);
expect(useGraphStore.getState().availableSubGraphs).toEqual([]);
});
});
});

View File

@@ -1,407 +0,0 @@
import { describe, it, expect, beforeEach } from "vitest";
import { useHistoryStore } from "../stores/historyStore";
import { useNodeStore } from "../stores/nodeStore";
import { useEdgeStore } from "../stores/edgeStore";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
function createTestNode(
id: string,
overrides: Partial<CustomNode> = {},
): CustomNode {
return {
id,
type: "custom" as const,
position: { x: 0, y: 0 },
data: {
hardcodedValues: {},
title: `Node ${id}`,
description: "",
inputSchema: {},
outputSchema: {},
uiType: "STANDARD" as never,
block_id: `block-${id}`,
costs: [],
categories: [],
},
...overrides,
} as CustomNode;
}
function createTestEdge(
id: string,
source: string,
target: string,
): CustomEdge {
return {
id,
source,
target,
type: "custom" as const,
} as CustomEdge;
}
async function flushMicrotasks() {
await new Promise<void>((resolve) => queueMicrotask(resolve));
}
beforeEach(() => {
useHistoryStore.getState().clear();
useNodeStore.setState({ nodes: [] });
useEdgeStore.setState({ edges: [] });
});
describe("historyStore", () => {
describe("undo/redo single action", () => {
it("undoes a single pushed state", async () => {
const node = createTestNode("1");
// Initialize history with node present as baseline
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().initializeHistory();
// Simulate a change: clear nodes
useNodeStore.setState({ nodes: [] });
// Undo should restore to [node]
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([node]);
expect(useHistoryStore.getState().future).toHaveLength(1);
expect(useHistoryStore.getState().future[0].nodes).toEqual([]);
});
it("redoes after undo", async () => {
const node = createTestNode("1");
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().initializeHistory();
// Change: clear nodes
useNodeStore.setState({ nodes: [] });
// Undo → back to [node]
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([node]);
// Redo → back to []
useHistoryStore.getState().redo();
expect(useNodeStore.getState().nodes).toEqual([]);
});
});
describe("undo/redo multiple actions", () => {
it("undoes through multiple states in order", async () => {
const node1 = createTestNode("1");
const node2 = createTestNode("2");
const node3 = createTestNode("3");
// Initialize with [node1] as baseline
useNodeStore.setState({ nodes: [node1] });
useHistoryStore.getState().initializeHistory();
// Second change: add node2, push pre-change state
useNodeStore.setState({ nodes: [node1, node2] });
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
await flushMicrotasks();
// Third change: add node3, push pre-change state
useNodeStore.setState({ nodes: [node1, node2, node3] });
useHistoryStore
.getState()
.pushState({ nodes: [node1, node2], edges: [] });
await flushMicrotasks();
// Undo 1: back to [node1, node2]
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([node1, node2]);
// Undo 2: back to [node1]
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([node1]);
});
});
describe("undo past empty history", () => {
it("does nothing when there is no history to undo", () => {
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([]);
expect(useEdgeStore.getState().edges).toEqual([]);
expect(useHistoryStore.getState().past).toHaveLength(1);
});
it("does nothing when current state equals last past entry", () => {
expect(useHistoryStore.getState().canUndo()).toBe(false);
useHistoryStore.getState().undo();
expect(useHistoryStore.getState().past).toHaveLength(1);
expect(useHistoryStore.getState().future).toHaveLength(0);
});
});
describe("state consistency: undo after node add restores previous, redo restores added", () => {
it("undo removes added node, redo restores it", async () => {
const node = createTestNode("added");
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([]);
useHistoryStore.getState().redo();
expect(useNodeStore.getState().nodes).toEqual([node]);
});
});
describe("history limits", () => {
it("does not grow past MAX_HISTORY (50)", async () => {
for (let i = 0; i < 60; i++) {
const node = createTestNode(`node-${i}`);
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().pushState({
nodes: [createTestNode(`node-${i - 1}`)],
edges: [],
});
await flushMicrotasks();
}
expect(useHistoryStore.getState().past.length).toBeLessThanOrEqual(50);
});
});
describe("edge cases", () => {
it("redo does nothing when future is empty", () => {
const nodesBefore = useNodeStore.getState().nodes;
const edgesBefore = useEdgeStore.getState().edges;
useHistoryStore.getState().redo();
expect(useNodeStore.getState().nodes).toEqual(nodesBefore);
expect(useEdgeStore.getState().edges).toEqual(edgesBefore);
});
it("interleaved undo/redo sequence", async () => {
const node1 = createTestNode("1");
const node2 = createTestNode("2");
const node3 = createTestNode("3");
useNodeStore.setState({ nodes: [node1] });
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
useNodeStore.setState({ nodes: [node1, node2] });
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
await flushMicrotasks();
useNodeStore.setState({ nodes: [node1, node2, node3] });
useHistoryStore.getState().pushState({
nodes: [node1, node2],
edges: [],
});
await flushMicrotasks();
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([node1, node2]);
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([node1]);
useHistoryStore.getState().redo();
expect(useNodeStore.getState().nodes).toEqual([node1, node2]);
useHistoryStore.getState().undo();
expect(useNodeStore.getState().nodes).toEqual([node1]);
useHistoryStore.getState().redo();
useHistoryStore.getState().redo();
expect(useNodeStore.getState().nodes).toEqual([node1, node2, node3]);
});
});
describe("canUndo / canRedo", () => {
it("canUndo is false on fresh store", () => {
expect(useHistoryStore.getState().canUndo()).toBe(false);
});
it("canUndo is true when current state differs from last past entry", async () => {
const node = createTestNode("1");
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
expect(useHistoryStore.getState().canUndo()).toBe(true);
});
it("canRedo is false on fresh store", () => {
expect(useHistoryStore.getState().canRedo()).toBe(false);
});
it("canRedo is true after undo", async () => {
const node = createTestNode("1");
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
useHistoryStore.getState().undo();
expect(useHistoryStore.getState().canRedo()).toBe(true);
});
it("canRedo becomes false after redo exhausts future", async () => {
const node = createTestNode("1");
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
useHistoryStore.getState().undo();
useHistoryStore.getState().redo();
expect(useHistoryStore.getState().canRedo()).toBe(false);
});
});
describe("pushState deduplication", () => {
it("does not push a state identical to the last past entry", async () => {
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
expect(useHistoryStore.getState().past).toHaveLength(1);
});
it("does not push if state matches current node/edge store state", async () => {
const node = createTestNode("1");
useNodeStore.setState({ nodes: [node] });
useEdgeStore.setState({ edges: [] });
useHistoryStore.getState().pushState({ nodes: [node], edges: [] });
await flushMicrotasks();
expect(useHistoryStore.getState().past).toHaveLength(1);
});
});
describe("initializeHistory", () => {
it("resets history with current node/edge store state", async () => {
const node = createTestNode("1");
const edge = createTestEdge("e1", "1", "2");
useNodeStore.setState({ nodes: [node] });
useEdgeStore.setState({ edges: [edge] });
useNodeStore.setState({ nodes: [node, createTestNode("2")] });
useHistoryStore.getState().pushState({ nodes: [node], edges: [edge] });
await flushMicrotasks();
useHistoryStore.getState().initializeHistory();
const { past, future } = useHistoryStore.getState();
expect(past).toHaveLength(1);
expect(past[0].nodes).toEqual(useNodeStore.getState().nodes);
expect(past[0].edges).toEqual(useEdgeStore.getState().edges);
expect(future).toHaveLength(0);
});
});
describe("clear", () => {
it("resets to empty initial state", async () => {
const node = createTestNode("1");
useNodeStore.setState({ nodes: [node] });
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
useHistoryStore.getState().clear();
const { past, future } = useHistoryStore.getState();
expect(past).toEqual([{ nodes: [], edges: [] }]);
expect(future).toEqual([]);
});
});
describe("microtask batching", () => {
it("only commits the first state when multiple pushState calls happen in the same tick", async () => {
const node1 = createTestNode("1");
const node2 = createTestNode("2");
const node3 = createTestNode("3");
useNodeStore.setState({ nodes: [node1, node2, node3] });
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
useHistoryStore.getState().pushState({ nodes: [node2], edges: [] });
useHistoryStore
.getState()
.pushState({ nodes: [node1, node2], edges: [] });
await flushMicrotasks();
const { past } = useHistoryStore.getState();
expect(past).toHaveLength(2);
expect(past[1].nodes).toEqual([node1]);
});
it("commits separately when pushState calls are in different ticks", async () => {
const node1 = createTestNode("1");
const node2 = createTestNode("2");
useNodeStore.setState({ nodes: [node1, node2] });
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
await flushMicrotasks();
useHistoryStore.getState().pushState({ nodes: [node2], edges: [] });
await flushMicrotasks();
const { past } = useHistoryStore.getState();
expect(past).toHaveLength(3);
expect(past[1].nodes).toEqual([node1]);
expect(past[2].nodes).toEqual([node2]);
});
});
describe("edges in undo/redo", () => {
it("restores edges on undo and redo", async () => {
const edge = createTestEdge("e1", "1", "2");
useEdgeStore.setState({ edges: [edge] });
useHistoryStore.getState().pushState({ nodes: [], edges: [] });
await flushMicrotasks();
useHistoryStore.getState().undo();
expect(useEdgeStore.getState().edges).toEqual([]);
useHistoryStore.getState().redo();
expect(useEdgeStore.getState().edges).toEqual([edge]);
});
});
describe("pushState clears future", () => {
it("clears future when a new state is pushed after undo", async () => {
const node1 = createTestNode("1");
const node2 = createTestNode("2");
const node3 = createTestNode("3");
// Initialize empty
useHistoryStore.getState().initializeHistory();
// First change: set [node1]
useNodeStore.setState({ nodes: [node1] });
// Second change: set [node1, node2], push pre-change [node1]
useNodeStore.setState({ nodes: [node1, node2] });
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
await flushMicrotasks();
// Undo: back to [node1]
useHistoryStore.getState().undo();
expect(useHistoryStore.getState().future).toHaveLength(1);
// New diverging change: add node3 instead of node2
useNodeStore.setState({ nodes: [node1, node3] });
useHistoryStore.getState().pushState({ nodes: [node1], edges: [] });
await flushMicrotasks();
expect(useHistoryStore.getState().future).toHaveLength(0);
});
});
});

View File

@@ -1,791 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { useNodeStore } from "../stores/nodeStore";
import { useHistoryStore } from "../stores/historyStore";
import { useEdgeStore } from "../stores/edgeStore";
import { BlockUIType } from "../components/types";
import type { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import type { CustomNodeData } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import type { NodeExecutionResult } from "@/app/api/__generated__/models/nodeExecutionResult";
function createTestNode(overrides: {
id: string;
position?: { x: number; y: number };
data?: Partial<CustomNodeData>;
}): CustomNode {
const defaults: CustomNodeData = {
hardcodedValues: {},
title: "Test Block",
description: "A test block",
inputSchema: {},
outputSchema: {},
uiType: BlockUIType.STANDARD,
block_id: "test-block-id",
costs: [],
categories: [],
};
return {
id: overrides.id,
type: "custom",
position: overrides.position ?? { x: 0, y: 0 },
data: { ...defaults, ...overrides.data },
};
}
function createExecutionResult(
overrides: Partial<NodeExecutionResult> = {},
): NodeExecutionResult {
return {
node_exec_id: overrides.node_exec_id ?? "exec-1",
node_id: overrides.node_id ?? "1",
graph_exec_id: overrides.graph_exec_id ?? "graph-exec-1",
graph_id: overrides.graph_id ?? "graph-1",
graph_version: overrides.graph_version ?? 1,
user_id: overrides.user_id ?? "test-user",
block_id: overrides.block_id ?? "block-1",
status: overrides.status ?? "COMPLETED",
input_data: overrides.input_data ?? { input_key: "input_value" },
output_data: overrides.output_data ?? { output_key: ["output_value"] },
add_time: overrides.add_time ?? new Date("2024-01-01T00:00:00Z"),
queue_time: overrides.queue_time ?? new Date("2024-01-01T00:00:00Z"),
start_time: overrides.start_time ?? new Date("2024-01-01T00:00:01Z"),
end_time: overrides.end_time ?? new Date("2024-01-01T00:00:02Z"),
};
}
function resetStores() {
useNodeStore.setState({
nodes: [],
nodeCounter: 0,
nodeAdvancedStates: {},
latestNodeInputData: {},
latestNodeOutputData: {},
accumulatedNodeInputData: {},
accumulatedNodeOutputData: {},
nodesInResolutionMode: new Set(),
brokenEdgeIDs: new Map(),
nodeResolutionData: new Map(),
});
useEdgeStore.setState({ edges: [] });
useHistoryStore.setState({ past: [], future: [] });
}
describe("nodeStore", () => {
beforeEach(() => {
resetStores();
vi.restoreAllMocks();
});
describe("node lifecycle", () => {
it("starts with empty nodes", () => {
const { nodes } = useNodeStore.getState();
expect(nodes).toEqual([]);
});
it("adds a single node with addNode", () => {
const node = createTestNode({ id: "1" });
useNodeStore.getState().addNode(node);
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe("1");
});
it("sets nodes with setNodes, replacing existing ones", () => {
const node1 = createTestNode({ id: "1" });
const node2 = createTestNode({ id: "2" });
useNodeStore.getState().addNode(node1);
useNodeStore.getState().setNodes([node2]);
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe("2");
});
it("removes nodes via onNodesChange", () => {
const node = createTestNode({ id: "1" });
useNodeStore.getState().setNodes([node]);
useNodeStore.getState().onNodesChange([{ type: "remove", id: "1" }]);
expect(useNodeStore.getState().nodes).toHaveLength(0);
});
it("updates node data with updateNodeData", () => {
const node = createTestNode({ id: "1" });
useNodeStore.getState().addNode(node);
useNodeStore.getState().updateNodeData("1", { title: "Updated Title" });
const updated = useNodeStore.getState().nodes[0];
expect(updated.data.title).toBe("Updated Title");
expect(updated.data.block_id).toBe("test-block-id");
});
it("updateNodeData does not affect other nodes", () => {
const node1 = createTestNode({ id: "1" });
const node2 = createTestNode({
id: "2",
data: { title: "Node 2" },
});
useNodeStore.getState().setNodes([node1, node2]);
useNodeStore.getState().updateNodeData("1", { title: "Changed" });
expect(useNodeStore.getState().nodes[1].data.title).toBe("Node 2");
});
});
describe("bulk operations", () => {
it("adds multiple nodes with addNodes", () => {
const nodes = [
createTestNode({ id: "1" }),
createTestNode({ id: "2" }),
createTestNode({ id: "3" }),
];
useNodeStore.getState().addNodes(nodes);
expect(useNodeStore.getState().nodes).toHaveLength(3);
});
it("removes multiple nodes via onNodesChange", () => {
const nodes = [
createTestNode({ id: "1" }),
createTestNode({ id: "2" }),
createTestNode({ id: "3" }),
];
useNodeStore.getState().setNodes(nodes);
useNodeStore.getState().onNodesChange([
{ type: "remove", id: "1" },
{ type: "remove", id: "3" },
]);
const remaining = useNodeStore.getState().nodes;
expect(remaining).toHaveLength(1);
expect(remaining[0].id).toBe("2");
});
});
describe("nodeCounter", () => {
it("starts at zero", () => {
expect(useNodeStore.getState().nodeCounter).toBe(0);
});
it("increments the counter", () => {
useNodeStore.getState().incrementNodeCounter();
expect(useNodeStore.getState().nodeCounter).toBe(1);
useNodeStore.getState().incrementNodeCounter();
expect(useNodeStore.getState().nodeCounter).toBe(2);
});
it("sets the counter to a specific value", () => {
useNodeStore.getState().setNodeCounter(42);
expect(useNodeStore.getState().nodeCounter).toBe(42);
});
});
describe("advanced states", () => {
it("defaults to false for unknown node IDs", () => {
expect(useNodeStore.getState().getShowAdvanced("unknown")).toBe(false);
});
it("toggles advanced state", () => {
useNodeStore.getState().toggleAdvanced("node-1");
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(true);
useNodeStore.getState().toggleAdvanced("node-1");
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(false);
});
it("sets advanced state explicitly", () => {
useNodeStore.getState().setShowAdvanced("node-1", true);
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(true);
useNodeStore.getState().setShowAdvanced("node-1", false);
expect(useNodeStore.getState().getShowAdvanced("node-1")).toBe(false);
});
});
describe("convertCustomNodeToBackendNode", () => {
it("converts a node with minimal data", () => {
const node = createTestNode({
id: "42",
position: { x: 100, y: 200 },
});
const backend = useNodeStore
.getState()
.convertCustomNodeToBackendNode(node);
expect(backend.id).toBe("42");
expect(backend.block_id).toBe("test-block-id");
expect(backend.input_default).toEqual({});
expect(backend.metadata).toEqual({ position: { x: 100, y: 200 } });
});
it("includes customized_name when present in metadata", () => {
const node = createTestNode({
id: "1",
data: {
metadata: { customized_name: "My Custom Name" },
},
});
const backend = useNodeStore
.getState()
.convertCustomNodeToBackendNode(node);
expect(backend.metadata).toHaveProperty(
"customized_name",
"My Custom Name",
);
});
it("includes credentials_optional when present in metadata", () => {
const node = createTestNode({
id: "1",
data: {
metadata: { credentials_optional: true },
},
});
const backend = useNodeStore
.getState()
.convertCustomNodeToBackendNode(node);
expect(backend.metadata).toHaveProperty("credentials_optional", true);
});
it("prunes empty values from hardcodedValues", () => {
const node = createTestNode({
id: "1",
data: {
hardcodedValues: { filled: "value", empty: "" },
},
});
const backend = useNodeStore
.getState()
.convertCustomNodeToBackendNode(node);
expect(backend.input_default).toEqual({ filled: "value" });
expect(backend.input_default).not.toHaveProperty("empty");
});
});
describe("getBackendNodes", () => {
it("converts all nodes to backend format", () => {
useNodeStore
.getState()
.setNodes([
createTestNode({ id: "1", position: { x: 0, y: 0 } }),
createTestNode({ id: "2", position: { x: 100, y: 100 } }),
]);
const backendNodes = useNodeStore.getState().getBackendNodes();
expect(backendNodes).toHaveLength(2);
expect(backendNodes[0].id).toBe("1");
expect(backendNodes[1].id).toBe("2");
});
});
describe("node status", () => {
it("returns undefined for a node with no status", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
expect(useNodeStore.getState().getNodeStatus("1")).toBeUndefined();
});
it("updates node status", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore.getState().updateNodeStatus("1", "RUNNING");
expect(useNodeStore.getState().getNodeStatus("1")).toBe("RUNNING");
useNodeStore.getState().updateNodeStatus("1", "COMPLETED");
expect(useNodeStore.getState().getNodeStatus("1")).toBe("COMPLETED");
});
it("cleans all node statuses", () => {
useNodeStore
.getState()
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
useNodeStore.getState().updateNodeStatus("1", "RUNNING");
useNodeStore.getState().updateNodeStatus("2", "COMPLETED");
useNodeStore.getState().cleanNodesStatuses();
expect(useNodeStore.getState().getNodeStatus("1")).toBeUndefined();
expect(useNodeStore.getState().getNodeStatus("2")).toBeUndefined();
});
it("updating status for non-existent node does not crash", () => {
useNodeStore.getState().updateNodeStatus("nonexistent", "RUNNING");
expect(
useNodeStore.getState().getNodeStatus("nonexistent"),
).toBeUndefined();
});
});
describe("execution result tracking", () => {
it("returns empty array for node with no results", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
expect(useNodeStore.getState().getNodeExecutionResults("1")).toEqual([]);
});
it("tracks a single execution result", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
const result = createExecutionResult({ node_id: "1" });
useNodeStore.getState().updateNodeExecutionResult("1", result);
const results = useNodeStore.getState().getNodeExecutionResults("1");
expect(results).toHaveLength(1);
expect(results[0].node_exec_id).toBe("exec-1");
});
it("accumulates multiple execution results", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-1",
input_data: { key: "val1" },
output_data: { key: ["out1"] },
}),
);
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-2",
input_data: { key: "val2" },
output_data: { key: ["out2"] },
}),
);
expect(useNodeStore.getState().getNodeExecutionResults("1")).toHaveLength(
2,
);
});
it("updates latest input/output data", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-1",
input_data: { key: "first" },
output_data: { key: ["first_out"] },
}),
);
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-2",
input_data: { key: "second" },
output_data: { key: ["second_out"] },
}),
);
expect(useNodeStore.getState().getLatestNodeInputData("1")).toEqual({
key: "second",
});
expect(useNodeStore.getState().getLatestNodeOutputData("1")).toEqual({
key: ["second_out"],
});
});
it("accumulates input/output data across results", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-1",
input_data: { key: "val1" },
output_data: { key: ["out1"] },
}),
);
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-2",
input_data: { key: "val2" },
output_data: { key: ["out2"] },
}),
);
const accInput = useNodeStore.getState().getAccumulatedNodeInputData("1");
expect(accInput.key).toEqual(["val1", "val2"]);
const accOutput = useNodeStore
.getState()
.getAccumulatedNodeOutputData("1");
expect(accOutput.key).toEqual(["out1", "out2"]);
});
it("deduplicates execution results by node_exec_id", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-1",
input_data: { key: "original" },
output_data: { key: ["original_out"] },
}),
);
useNodeStore.getState().updateNodeExecutionResult(
"1",
createExecutionResult({
node_exec_id: "exec-1",
input_data: { key: "updated" },
output_data: { key: ["updated_out"] },
}),
);
const results = useNodeStore.getState().getNodeExecutionResults("1");
expect(results).toHaveLength(1);
expect(results[0].input_data).toEqual({ key: "updated" });
});
it("returns the latest execution result", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore
.getState()
.updateNodeExecutionResult(
"1",
createExecutionResult({ node_exec_id: "exec-1" }),
);
useNodeStore
.getState()
.updateNodeExecutionResult(
"1",
createExecutionResult({ node_exec_id: "exec-2" }),
);
const latest = useNodeStore.getState().getLatestNodeExecutionResult("1");
expect(latest?.node_exec_id).toBe("exec-2");
});
it("returns undefined for latest result on unknown node", () => {
expect(
useNodeStore.getState().getLatestNodeExecutionResult("unknown"),
).toBeUndefined();
});
it("clears all execution results", () => {
useNodeStore
.getState()
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
useNodeStore
.getState()
.updateNodeExecutionResult(
"1",
createExecutionResult({ node_exec_id: "exec-1" }),
);
useNodeStore
.getState()
.updateNodeExecutionResult(
"2",
createExecutionResult({ node_exec_id: "exec-2" }),
);
useNodeStore.getState().clearAllNodeExecutionResults();
expect(useNodeStore.getState().getNodeExecutionResults("1")).toEqual([]);
expect(useNodeStore.getState().getNodeExecutionResults("2")).toEqual([]);
expect(
useNodeStore.getState().getLatestNodeInputData("1"),
).toBeUndefined();
expect(
useNodeStore.getState().getLatestNodeOutputData("1"),
).toBeUndefined();
expect(useNodeStore.getState().getAccumulatedNodeInputData("1")).toEqual(
{},
);
expect(useNodeStore.getState().getAccumulatedNodeOutputData("1")).toEqual(
{},
);
});
it("returns empty object for accumulated data on unknown node", () => {
expect(
useNodeStore.getState().getAccumulatedNodeInputData("unknown"),
).toEqual({});
expect(
useNodeStore.getState().getAccumulatedNodeOutputData("unknown"),
).toEqual({});
});
});
describe("getNodeBlockUIType", () => {
it("returns the node UI type", () => {
useNodeStore.getState().addNode(
createTestNode({
id: "1",
data: {
uiType: BlockUIType.INPUT,
},
}),
);
expect(useNodeStore.getState().getNodeBlockUIType("1")).toBe(
BlockUIType.INPUT,
);
});
it("defaults to STANDARD for unknown node IDs", () => {
expect(useNodeStore.getState().getNodeBlockUIType("unknown")).toBe(
BlockUIType.STANDARD,
);
});
});
describe("hasWebhookNodes", () => {
it("returns false when there are no webhook nodes", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
expect(useNodeStore.getState().hasWebhookNodes()).toBe(false);
});
it("returns true when a WEBHOOK node exists", () => {
useNodeStore.getState().addNode(
createTestNode({
id: "1",
data: {
uiType: BlockUIType.WEBHOOK,
},
}),
);
expect(useNodeStore.getState().hasWebhookNodes()).toBe(true);
});
it("returns true when a WEBHOOK_MANUAL node exists", () => {
useNodeStore.getState().addNode(
createTestNode({
id: "1",
data: {
uiType: BlockUIType.WEBHOOK_MANUAL,
},
}),
);
expect(useNodeStore.getState().hasWebhookNodes()).toBe(true);
});
});
describe("node errors", () => {
it("returns undefined for a node with no errors", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
expect(useNodeStore.getState().getNodeErrors("1")).toBeUndefined();
});
it("sets and retrieves node errors", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
const errors = { field1: "required", field2: "invalid" };
useNodeStore.getState().updateNodeErrors("1", errors);
expect(useNodeStore.getState().getNodeErrors("1")).toEqual(errors);
});
it("clears errors for a specific node", () => {
useNodeStore
.getState()
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
useNodeStore.getState().updateNodeErrors("1", { f: "err" });
useNodeStore.getState().updateNodeErrors("2", { g: "err2" });
useNodeStore.getState().clearNodeErrors("1");
expect(useNodeStore.getState().getNodeErrors("1")).toBeUndefined();
expect(useNodeStore.getState().getNodeErrors("2")).toEqual({ g: "err2" });
});
it("clears all node errors", () => {
useNodeStore
.getState()
.setNodes([createTestNode({ id: "1" }), createTestNode({ id: "2" })]);
useNodeStore.getState().updateNodeErrors("1", { a: "err1" });
useNodeStore.getState().updateNodeErrors("2", { b: "err2" });
useNodeStore.getState().clearAllNodeErrors();
expect(useNodeStore.getState().getNodeErrors("1")).toBeUndefined();
expect(useNodeStore.getState().getNodeErrors("2")).toBeUndefined();
});
it("sets errors by backend ID matching node id", () => {
useNodeStore.getState().addNode(createTestNode({ id: "backend-1" }));
useNodeStore
.getState()
.setNodeErrorsForBackendId("backend-1", { x: "error" });
expect(useNodeStore.getState().getNodeErrors("backend-1")).toEqual({
x: "error",
});
});
});
describe("getHardCodedValues", () => {
it("returns hardcoded values for a node", () => {
useNodeStore.getState().addNode(
createTestNode({
id: "1",
data: {
hardcodedValues: { key: "value" },
},
}),
);
expect(useNodeStore.getState().getHardCodedValues("1")).toEqual({
key: "value",
});
});
it("returns empty object for unknown node", () => {
expect(useNodeStore.getState().getHardCodedValues("unknown")).toEqual({});
});
});
describe("credentials optional", () => {
it("sets credentials_optional in node metadata", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore.getState().setCredentialsOptional("1", true);
const node = useNodeStore.getState().nodes[0];
expect(node.data.metadata?.credentials_optional).toBe(true);
});
});
describe("resolution mode", () => {
it("defaults to not in resolution mode", () => {
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(false);
});
it("enters and exits resolution mode", () => {
useNodeStore.getState().setNodeResolutionMode("1", true);
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(true);
useNodeStore.getState().setNodeResolutionMode("1", false);
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(false);
});
it("tracks broken edge IDs", () => {
useNodeStore.getState().setBrokenEdgeIDs("node-1", ["edge-1", "edge-2"]);
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(true);
expect(useNodeStore.getState().isEdgeBroken("edge-2")).toBe(true);
expect(useNodeStore.getState().isEdgeBroken("edge-3")).toBe(false);
});
it("removes individual broken edge IDs", () => {
useNodeStore.getState().setBrokenEdgeIDs("node-1", ["edge-1", "edge-2"]);
useNodeStore.getState().removeBrokenEdgeID("node-1", "edge-1");
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(false);
expect(useNodeStore.getState().isEdgeBroken("edge-2")).toBe(true);
});
it("clears all resolution state", () => {
useNodeStore.getState().setNodeResolutionMode("1", true);
useNodeStore.getState().setBrokenEdgeIDs("1", ["edge-1"]);
useNodeStore.getState().clearResolutionState();
expect(useNodeStore.getState().isNodeInResolutionMode("1")).toBe(false);
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(false);
});
it("cleans up broken edges when exiting resolution mode", () => {
useNodeStore.getState().setNodeResolutionMode("1", true);
useNodeStore.getState().setBrokenEdgeIDs("1", ["edge-1"]);
useNodeStore.getState().setNodeResolutionMode("1", false);
expect(useNodeStore.getState().isEdgeBroken("edge-1")).toBe(false);
});
});
describe("edge cases", () => {
it("handles updating data on a non-existent node gracefully", () => {
useNodeStore
.getState()
.updateNodeData("nonexistent", { title: "New Title" });
expect(useNodeStore.getState().nodes).toHaveLength(0);
});
it("handles removing a non-existent node gracefully", () => {
useNodeStore.getState().addNode(createTestNode({ id: "1" }));
useNodeStore
.getState()
.onNodesChange([{ type: "remove", id: "nonexistent" }]);
expect(useNodeStore.getState().nodes).toHaveLength(1);
});
it("handles duplicate node IDs in addNodes", () => {
useNodeStore.getState().addNodes([
createTestNode({
id: "1",
data: { title: "First" },
}),
createTestNode({
id: "1",
data: { title: "Second" },
}),
]);
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(2);
expect(nodes[0].data.title).toBe("First");
expect(nodes[1].data.title).toBe("Second");
});
it("updating node status mid-execution preserves other data", () => {
useNodeStore.getState().addNode(
createTestNode({
id: "1",
data: {
title: "My Node",
hardcodedValues: { key: "val" },
},
}),
);
useNodeStore.getState().updateNodeStatus("1", "RUNNING");
const node = useNodeStore.getState().nodes[0];
expect(node.data.status).toBe("RUNNING");
expect(node.data.title).toBe("My Node");
expect(node.data.hardcodedValues).toEqual({ key: "val" });
});
it("execution result for non-existent node does not add it", () => {
useNodeStore
.getState()
.updateNodeExecutionResult(
"nonexistent",
createExecutionResult({ node_exec_id: "exec-1" }),
);
expect(useNodeStore.getState().nodes).toHaveLength(0);
expect(
useNodeStore.getState().getNodeExecutionResults("nonexistent"),
).toEqual([]);
});
it("getBackendNodes returns empty array when no nodes exist", () => {
expect(useNodeStore.getState().getBackendNodes()).toEqual([]);
});
});
});

View File

@@ -1,567 +0,0 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { BlockUIType } from "../components/types";
// ---- Mocks ----
const mockGetViewport = vi.fn(() => ({ x: 0, y: 0, zoom: 1 }));
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual("@xyflow/react");
return {
...actual,
useReactFlow: vi.fn(() => ({
getViewport: mockGetViewport,
})),
};
});
const mockToast = vi.fn();
vi.mock("@/components/molecules/Toast/use-toast", () => ({
useToast: vi.fn(() => ({ toast: mockToast })),
}));
let uuidCounter = 0;
vi.mock("uuid", () => ({
v4: vi.fn(() => `new-uuid-${++uuidCounter}`),
}));
// Mock navigator.clipboard
const mockWriteText = vi.fn(() => Promise.resolve());
const mockReadText = vi.fn(() => Promise.resolve(""));
Object.defineProperty(navigator, "clipboard", {
value: {
writeText: mockWriteText,
readText: mockReadText,
},
writable: true,
configurable: true,
});
// Mock window.innerWidth / innerHeight for viewport centering calculations
Object.defineProperty(window, "innerWidth", { value: 1000, writable: true });
Object.defineProperty(window, "innerHeight", { value: 800, writable: true });
import { useCopyPaste } from "../components/FlowEditor/Flow/useCopyPaste";
import { useNodeStore } from "../stores/nodeStore";
import { useEdgeStore } from "../stores/edgeStore";
import { useHistoryStore } from "../stores/historyStore";
import { CustomEdge } from "../components/FlowEditor/edges/CustomEdge";
const CLIPBOARD_PREFIX = "autogpt-flow-data:";
function createTestNode(
id: string,
overrides: Partial<CustomNode> = {},
): CustomNode {
return {
id,
type: "custom",
position: overrides.position ?? { x: 100, y: 200 },
selected: overrides.selected,
data: {
hardcodedValues: {},
title: `Node ${id}`,
description: "test node",
inputSchema: {},
outputSchema: {},
uiType: BlockUIType.STANDARD,
block_id: `block-${id}`,
costs: [],
categories: [],
...overrides.data,
},
} as CustomNode;
}
function createTestEdge(
id: string,
source: string,
target: string,
sourceHandle = "out",
targetHandle = "in",
): CustomEdge {
return {
id,
source,
target,
sourceHandle,
targetHandle,
} as CustomEdge;
}
function makeCopyEvent(): KeyboardEvent {
return new KeyboardEvent("keydown", {
key: "c",
ctrlKey: true,
bubbles: true,
});
}
function makePasteEvent(): KeyboardEvent {
return new KeyboardEvent("keydown", {
key: "v",
ctrlKey: true,
bubbles: true,
});
}
function clipboardPayload(nodes: CustomNode[], edges: CustomEdge[]): string {
return `${CLIPBOARD_PREFIX}${JSON.stringify({ nodes, edges })}`;
}
describe("useCopyPaste", () => {
beforeEach(() => {
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
useEdgeStore.setState({ edges: [] });
useHistoryStore.getState().clear();
mockWriteText.mockClear();
mockReadText.mockClear();
mockToast.mockClear();
mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 });
uuidCounter = 0;
// Ensure no input element is focused
if (document.activeElement && document.activeElement !== document.body) {
(document.activeElement as HTMLElement).blur();
}
});
describe("copy (Ctrl+C)", () => {
it("copies a single selected node to clipboard with prefix", async () => {
const node = createTestNode("1", { selected: true });
useNodeStore.setState({ nodes: [node] });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makeCopyEvent());
});
await vi.waitFor(() => {
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
const written = (mockWriteText.mock.calls as string[][])[0][0];
expect(written.startsWith(CLIPBOARD_PREFIX)).toBe(true);
const parsed = JSON.parse(written.slice(CLIPBOARD_PREFIX.length));
expect(parsed.nodes).toHaveLength(1);
expect(parsed.nodes[0].id).toBe("1");
expect(parsed.edges).toHaveLength(0);
});
it("shows a success toast after copying", async () => {
const node = createTestNode("1", { selected: true });
useNodeStore.setState({ nodes: [node] });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makeCopyEvent());
});
await vi.waitFor(() => {
expect(mockToast).toHaveBeenCalledWith(
expect.objectContaining({
title: "Copied successfully",
}),
);
});
});
it("copies multiple connected nodes and preserves internal edges", async () => {
const nodeA = createTestNode("a", { selected: true });
const nodeB = createTestNode("b", { selected: true });
const nodeC = createTestNode("c", { selected: false });
useNodeStore.setState({ nodes: [nodeA, nodeB, nodeC] });
useEdgeStore.setState({
edges: [
createTestEdge("e-ab", "a", "b"),
createTestEdge("e-bc", "b", "c"),
],
});
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makeCopyEvent());
});
await vi.waitFor(() => {
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
const parsed = JSON.parse(
(mockWriteText.mock.calls as string[][])[0][0].slice(
CLIPBOARD_PREFIX.length,
),
);
expect(parsed.nodes).toHaveLength(2);
expect(parsed.edges).toHaveLength(1);
expect(parsed.edges[0].id).toBe("e-ab");
});
it("drops external edges where one endpoint is not selected", async () => {
const nodeA = createTestNode("a", { selected: true });
const nodeB = createTestNode("b", { selected: false });
useNodeStore.setState({ nodes: [nodeA, nodeB] });
useEdgeStore.setState({
edges: [createTestEdge("e-ab", "a", "b")],
});
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makeCopyEvent());
});
await vi.waitFor(() => {
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
const parsed = JSON.parse(
(mockWriteText.mock.calls as string[][])[0][0].slice(
CLIPBOARD_PREFIX.length,
),
);
expect(parsed.nodes).toHaveLength(1);
expect(parsed.edges).toHaveLength(0);
});
it("copies nothing when no nodes are selected", async () => {
const node = createTestNode("1", { selected: false });
useNodeStore.setState({ nodes: [node] });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makeCopyEvent());
});
await vi.waitFor(() => {
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
const parsed = JSON.parse(
(mockWriteText.mock.calls as string[][])[0][0].slice(
CLIPBOARD_PREFIX.length,
),
);
expect(parsed.nodes).toHaveLength(0);
expect(parsed.edges).toHaveLength(0);
});
});
describe("paste (Ctrl+V)", () => {
it("creates new nodes with new UUIDs", async () => {
const node = createTestNode("orig", {
selected: true,
position: { x: 100, y: 200 },
});
mockReadText.mockResolvedValue(clipboardPayload([node], []));
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makePasteEvent());
});
await vi.waitFor(() => {
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
});
const { nodes } = useNodeStore.getState();
expect(nodes[0].id).toBe("new-uuid-1");
expect(nodes[0].id).not.toBe("orig");
});
it("centers pasted nodes in the current viewport", async () => {
// Viewport at origin, zoom 1 => center = (500, 400)
mockGetViewport.mockReturnValue({ x: 0, y: 0, zoom: 1 });
const node = createTestNode("orig", {
selected: true,
position: { x: 100, y: 100 },
});
mockReadText.mockResolvedValue(clipboardPayload([node], []));
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makePasteEvent());
});
await vi.waitFor(() => {
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
});
const { nodes } = useNodeStore.getState();
// Single node: center of bounds = (100, 100)
// Viewport center = (500, 400)
// Offset = (400, 300)
// New position = (100 + 400, 100 + 300) = (500, 400)
expect(nodes[0].position).toEqual({ x: 500, y: 400 });
});
it("deselects existing nodes and selects pasted nodes", async () => {
const existingNode = createTestNode("existing", {
selected: true,
position: { x: 0, y: 0 },
});
useNodeStore.setState({ nodes: [existingNode], nodeCounter: 0 });
const nodeToPaste = createTestNode("paste-me", {
selected: false,
position: { x: 100, y: 100 },
});
mockReadText.mockResolvedValue(clipboardPayload([nodeToPaste], []));
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makePasteEvent());
});
await vi.waitFor(() => {
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(2);
});
const { nodes } = useNodeStore.getState();
const originalNode = nodes.find((n) => n.id === "existing");
const pastedNode = nodes.find((n) => n.id !== "existing");
expect(originalNode!.selected).toBe(false);
expect(pastedNode!.selected).toBe(true);
});
it("remaps edge source/target IDs to newly created node IDs", async () => {
const nodeA = createTestNode("a", {
selected: true,
position: { x: 0, y: 0 },
});
const nodeB = createTestNode("b", {
selected: true,
position: { x: 200, y: 0 },
});
const edge = createTestEdge("e-ab", "a", "b", "output", "input");
mockReadText.mockResolvedValue(clipboardPayload([nodeA, nodeB], [edge]));
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
useEdgeStore.setState({ edges: [] });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makePasteEvent());
});
await vi.waitFor(() => {
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(2);
});
// Wait for edges to be added too
await vi.waitFor(() => {
const { edges } = useEdgeStore.getState();
expect(edges).toHaveLength(1);
});
const { edges } = useEdgeStore.getState();
const newEdge = edges[0];
// Edge source/target should be remapped to new UUIDs, not "a"/"b"
expect(newEdge.source).not.toBe("a");
expect(newEdge.target).not.toBe("b");
expect(newEdge.source).toBe("new-uuid-1");
expect(newEdge.target).toBe("new-uuid-2");
expect(newEdge.sourceHandle).toBe("output");
expect(newEdge.targetHandle).toBe("input");
});
it("does nothing when clipboard does not have the expected prefix", async () => {
mockReadText.mockResolvedValue("some random text");
const existingNode = createTestNode("1", { position: { x: 0, y: 0 } });
useNodeStore.setState({ nodes: [existingNode], nodeCounter: 0 });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makePasteEvent());
});
// Give async operations time to settle
await vi.waitFor(() => {
expect(mockReadText).toHaveBeenCalled();
});
// Ensure no state changes happen after clipboard read
await vi.waitFor(() => {
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe("1");
});
});
it("does nothing when clipboard is empty", async () => {
mockReadText.mockResolvedValue("");
const existingNode = createTestNode("1", { position: { x: 0, y: 0 } });
useNodeStore.setState({ nodes: [existingNode], nodeCounter: 0 });
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makePasteEvent());
});
await vi.waitFor(() => {
expect(mockReadText).toHaveBeenCalled();
});
// Ensure no state changes happen after clipboard read
await vi.waitFor(() => {
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
expect(nodes[0].id).toBe("1");
});
});
});
describe("input field focus guard", () => {
it("ignores Ctrl+C when an input element is focused", async () => {
const node = createTestNode("1", { selected: true });
useNodeStore.setState({ nodes: [node] });
const input = document.createElement("input");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makeCopyEvent());
});
// Clipboard write should NOT be called
expect(mockWriteText).not.toHaveBeenCalled();
document.body.removeChild(input);
});
it("ignores Ctrl+V when a textarea element is focused", async () => {
mockReadText.mockResolvedValue(
clipboardPayload(
[createTestNode("a", { position: { x: 0, y: 0 } })],
[],
),
);
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
const textarea = document.createElement("textarea");
document.body.appendChild(textarea);
textarea.focus();
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makePasteEvent());
});
expect(mockReadText).not.toHaveBeenCalled();
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(0);
document.body.removeChild(textarea);
});
it("ignores keypresses when a contenteditable element is focused", async () => {
const node = createTestNode("1", { selected: true });
useNodeStore.setState({ nodes: [node] });
const div = document.createElement("div");
div.setAttribute("contenteditable", "true");
document.body.appendChild(div);
div.focus();
const { result } = renderHook(() => useCopyPaste());
act(() => {
result.current(makeCopyEvent());
});
expect(mockWriteText).not.toHaveBeenCalled();
document.body.removeChild(div);
});
});
describe("meta key support (macOS)", () => {
it("handles Cmd+C (metaKey) the same as Ctrl+C", async () => {
const node = createTestNode("1", { selected: true });
useNodeStore.setState({ nodes: [node] });
const { result } = renderHook(() => useCopyPaste());
const metaCopyEvent = new KeyboardEvent("keydown", {
key: "c",
metaKey: true,
bubbles: true,
});
act(() => {
result.current(metaCopyEvent);
});
await vi.waitFor(() => {
expect(mockWriteText).toHaveBeenCalledTimes(1);
});
});
it("handles Cmd+V (metaKey) the same as Ctrl+V", async () => {
const node = createTestNode("orig", {
selected: true,
position: { x: 0, y: 0 },
});
mockReadText.mockResolvedValue(clipboardPayload([node], []));
useNodeStore.setState({ nodes: [], nodeCounter: 0 });
const { result } = renderHook(() => useCopyPaste());
const metaPasteEvent = new KeyboardEvent("keydown", {
key: "v",
metaKey: true,
bubbles: true,
});
act(() => {
result.current(metaPasteEvent);
});
await vi.waitFor(() => {
const { nodes } = useNodeStore.getState();
expect(nodes).toHaveLength(1);
});
});
});
});

View File

@@ -1,134 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
const mockScreenToFlowPosition = vi.fn((pos: { x: number; y: number }) => pos);
const mockFitView = vi.fn();
vi.mock("@xyflow/react", async () => {
const actual = await vi.importActual("@xyflow/react");
return {
...actual,
useReactFlow: () => ({
screenToFlowPosition: mockScreenToFlowPosition,
fitView: mockFitView,
}),
};
});
const mockSetQueryStates = vi.fn();
let mockQueryStateValues: {
flowID: string | null;
flowVersion: number | null;
flowExecutionID: string | null;
} = {
flowID: null,
flowVersion: null,
flowExecutionID: null,
};
vi.mock("nuqs", () => ({
parseAsString: {},
parseAsInteger: {},
useQueryStates: vi.fn(() => [mockQueryStateValues, mockSetQueryStates]),
}));
let mockGraphLoading = false;
let mockBlocksLoading = false;
vi.mock("@/app/api/__generated__/endpoints/graphs/graphs", () => ({
useGetV1GetSpecificGraph: vi.fn(() => ({
data: undefined,
isLoading: mockGraphLoading,
})),
useGetV1GetExecutionDetails: vi.fn(() => ({
data: undefined,
})),
useGetV1ListUserGraphs: vi.fn(() => ({
data: undefined,
})),
}));
vi.mock("@/app/api/__generated__/endpoints/default/default", () => ({
useGetV2GetSpecificBlocks: vi.fn(() => ({
data: undefined,
isLoading: mockBlocksLoading,
})),
}));
vi.mock("@/app/api/helpers", () => ({
okData: (res: { data: unknown }) => res?.data,
}));
vi.mock("../components/helper", () => ({
convertNodesPlusBlockInfoIntoCustomNodes: vi.fn(),
}));
describe("useFlow", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
mockGraphLoading = false;
mockBlocksLoading = false;
mockQueryStateValues = {
flowID: null,
flowVersion: null,
flowExecutionID: null,
};
});
afterEach(() => {
vi.useRealTimers();
});
describe("loading states", () => {
it("returns isFlowContentLoading true when graph is loading", async () => {
mockGraphLoading = true;
mockQueryStateValues = {
flowID: "test-flow",
flowVersion: 1,
flowExecutionID: null,
};
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
const { result } = renderHook(() => useFlow());
expect(result.current.isFlowContentLoading).toBe(true);
});
it("returns isFlowContentLoading true when blocks are loading", async () => {
mockBlocksLoading = true;
mockQueryStateValues = {
flowID: "test-flow",
flowVersion: 1,
flowExecutionID: null,
};
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
const { result } = renderHook(() => useFlow());
expect(result.current.isFlowContentLoading).toBe(true);
});
it("returns isFlowContentLoading false when neither is loading", async () => {
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
const { result } = renderHook(() => useFlow());
expect(result.current.isFlowContentLoading).toBe(false);
});
});
describe("initial load completion", () => {
it("marks initial load complete for new flows without flowID", async () => {
const { useFlow } = await import("../components/FlowEditor/Flow/useFlow");
const { result } = renderHook(() => useFlow());
expect(result.current.isInitialLoadComplete).toBe(false);
await act(async () => {
vi.advanceTimersByTime(300);
});
expect(result.current.isInitialLoadComplete).toBe(true);
});
});
});

View File

@@ -1,4 +1,3 @@
import { useCopilotUIStore } from "@/app/(platform)/copilot/store";
import { ChangeEvent, FormEvent, useEffect, useState } from "react";
interface Args {
@@ -17,16 +16,6 @@ export function useChatInput({
}: Args) {
const [value, setValue] = useState("");
const [isSending, setIsSending] = useState(false);
const { initialPrompt, setInitialPrompt } = useCopilotUIStore();
useEffect(
function consumeInitialPrompt() {
if (!initialPrompt) return;
setValue((prev) => (prev.length === 0 ? initialPrompt : prev));
setInitialPrompt(null);
},
[initialPrompt, setInitialPrompt],
);
useEffect(
function focusOnMount() {

View File

@@ -7,10 +7,6 @@ export interface DeleteTarget {
}
interface CopilotUIState {
/** Prompt extracted from URL hash (e.g. /copilot#prompt=...) for input prefill. */
initialPrompt: string | null;
setInitialPrompt: (prompt: string | null) => void;
sessionToDelete: DeleteTarget | null;
setSessionToDelete: (target: DeleteTarget | null) => void;
@@ -35,9 +31,6 @@ interface CopilotUIState {
}
export const useCopilotUIStore = create<CopilotUIState>((set) => ({
initialPrompt: null,
setInitialPrompt: (prompt) => set({ initialPrompt: prompt }),
sessionToDelete: null,
setSessionToDelete: (target) => set({ sessionToDelete: target }),

View File

@@ -19,42 +19,6 @@ import { useCopilotStream } from "./useCopilotStream";
const TITLE_POLL_INTERVAL_MS = 2_000;
const TITLE_POLL_MAX_ATTEMPTS = 5;
/**
* Extract a prompt from the URL hash fragment.
* Supports: /copilot#prompt=URL-encoded-text
* Optionally auto-submits if ?autosubmit=true is in the query string.
* Returns null if no prompt is present.
*/
function extractPromptFromUrl(): {
prompt: string;
autosubmit: boolean;
} | null {
if (typeof window === "undefined") return null;
const hash = window.location.hash;
if (!hash) return null;
const hashParams = new URLSearchParams(hash.slice(1));
const prompt = hashParams.get("prompt");
if (!prompt || !prompt.trim()) return null;
const searchParams = new URLSearchParams(window.location.search);
const autosubmit = searchParams.get("autosubmit") === "true";
// Clean up hash + autosubmit param only (preserve other query params)
const cleanURL = new URL(window.location.href);
cleanURL.hash = "";
cleanURL.searchParams.delete("autosubmit");
window.history.replaceState(
null,
"",
`${cleanURL.pathname}${cleanURL.search}`,
);
return { prompt: prompt.trim(), autosubmit };
}
interface UploadedFile {
file_id: string;
name: string;
@@ -163,28 +127,6 @@ export function useCopilotPage() {
}
}, [sessionId, pendingMessage, sendMessage]);
// --- Extract prompt from URL hash on mount (e.g. /copilot#prompt=Hello) ---
const { setInitialPrompt } = useCopilotUIStore();
const hasProcessedUrlPrompt = useRef(false);
useEffect(() => {
if (hasProcessedUrlPrompt.current) return;
const urlPrompt = extractPromptFromUrl();
if (!urlPrompt) return;
hasProcessedUrlPrompt.current = true;
if (urlPrompt.autosubmit) {
setPendingMessage(urlPrompt.prompt);
void createSession().catch(() => {
setPendingMessage(null);
setInitialPrompt(urlPrompt.prompt);
});
} else {
setInitialPrompt(urlPrompt.prompt);
}
}, [createSession, setInitialPrompt]);
async function uploadFiles(
files: File[],
sid: string,

View File

@@ -1,343 +0,0 @@
# Workspace & Media File Architecture
This document describes the architecture for handling user files in AutoGPT Platform, covering persistent user storage (Workspace) and ephemeral media processing pipelines.
## Overview
The platform has two distinct file-handling layers:
| Layer | Purpose | Persistence | Scope |
|-------|---------|-------------|-------|
| **Workspace** | Long-term user file storage | Persistent (DB + GCS/local) | Per-user, session-scoped access |
| **Media Pipeline** | Ephemeral file processing for blocks | Temporary (local disk) | Per-execution |
## Database Models
### UserWorkspace
Represents a user's file storage space. Created on-demand (one per user).
```prisma
model UserWorkspace {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
userId String @unique
Files UserWorkspaceFile[]
}
```
**Key points:**
- One workspace per user (enforced by `@unique` on `userId`)
- Created lazily via `get_or_create_workspace()`
- Uses upsert to handle race conditions
### UserWorkspaceFile
Represents a file stored in a user's workspace.
```prisma
model UserWorkspaceFile {
id String @id @default(uuid())
workspaceId String
name String // User-visible filename
path String // Virtual path (e.g., "/sessions/abc123/image.png")
storagePath String // Actual storage path (gcs://... or local://...)
mimeType String
sizeBytes BigInt
checksum String? // SHA256 for integrity
isDeleted Boolean @default(false)
deletedAt DateTime?
metadata Json @default("{}")
@@unique([workspaceId, path]) // Enforce unique paths within workspace
}
```
**Key points:**
- `path` is a virtual path for organizing files (not actual filesystem path)
- `storagePath` contains the actual GCS or local storage location
- Soft-delete pattern: `isDeleted` flag with `deletedAt` timestamp
- Path is modified on delete to free up the virtual path for reuse
---
## WorkspaceManager
**Location:** `backend/util/workspace.py`
High-level API for workspace file operations. Combines storage backend operations with database record management.
### Initialization
```python
from backend.util.workspace import WorkspaceManager
# Basic usage
manager = WorkspaceManager(user_id="user-123", workspace_id="ws-456")
# With session scoping (CoPilot sessions)
manager = WorkspaceManager(
user_id="user-123",
workspace_id="ws-456",
session_id="session-789"
)
```
### Session Scoping
When `session_id` is provided, files are isolated to `/sessions/{session_id}/`:
```python
# With session_id="abc123":
manager.write_file(content, "image.png")
# → stored at /sessions/abc123/image.png
# Cross-session access is explicit:
manager.read_file("/sessions/other-session/file.txt") # Works
```
**Why session scoping?**
- CoPilot conversations need file isolation
- Prevents file collisions between concurrent sessions
- Allows session cleanup without affecting other sessions
### Core Methods
| Method | Description |
|--------|-------------|
| `write_file(content, filename, path?, mime_type?, overwrite?)` | Write file to workspace |
| `read_file(path)` | Read file by virtual path |
| `read_file_by_id(file_id)` | Read file by ID |
| `list_files(path?, limit?, offset?, include_all_sessions?)` | List files |
| `delete_file(file_id)` | Soft-delete a file |
| `get_download_url(file_id, expires_in?)` | Get signed download URL |
| `get_file_info(file_id)` | Get file metadata |
| `get_file_info_by_path(path)` | Get file metadata by path |
| `get_file_count(path?, include_all_sessions?)` | Count files |
### Storage Backends
WorkspaceManager delegates to `WorkspaceStorageBackend`:
| Backend | When Used | Storage Path Format |
|---------|-----------|---------------------|
| `GCSWorkspaceStorage` | `media_gcs_bucket_name` is configured | `gcs://bucket/workspaces/{ws_id}/{file_id}/{filename}` |
| `LocalWorkspaceStorage` | No GCS bucket configured | `local://{ws_id}/{file_id}/{filename}` |
---
## store_media_file()
**Location:** `backend/util/file.py`
The media normalization pipeline. Handles various input types and normalizes them for processing or output.
### Purpose
Blocks receive files in many formats (URLs, data URIs, workspace references, local paths). `store_media_file()` normalizes these to a consistent format based on what the block needs.
### Input Types Handled
| Input Format | Example | How It's Processed |
|--------------|---------|-------------------|
| Data URI | `data:image/png;base64,iVBOR...` | Decoded, virus scanned, written locally |
| HTTP(S) URL | `https://example.com/image.png` | Downloaded, virus scanned, written locally |
| Workspace URI | `workspace://abc123` or `workspace:///path/to/file` | Read from workspace, virus scanned, written locally |
| Cloud path | `gcs://bucket/path` | Downloaded, virus scanned, written locally |
| Local path | `image.png` | Verified to exist in exec_file directory |
### Return Formats
The `return_format` parameter determines what you get back:
```python
from backend.util.file import store_media_file
# For local processing (ffmpeg, MoviePy, PIL)
local_path = await store_media_file(
file=input_file,
execution_context=ctx,
return_format="for_local_processing"
)
# Returns: "image.png" (relative path in exec_file dir)
# For external APIs (Replicate, OpenAI, etc.)
data_uri = await store_media_file(
file=input_file,
execution_context=ctx,
return_format="for_external_api"
)
# Returns: "data:image/png;base64,iVBOR..."
# For block output (adapts to execution context)
output = await store_media_file(
file=input_file,
execution_context=ctx,
return_format="for_block_output"
)
# In CoPilot: Returns "workspace://file-id#image/png"
# In graphs: Returns "data:image/png;base64,..."
```
### Execution Context
`store_media_file()` requires an `ExecutionContext` with:
- `graph_exec_id` - Required for temp file location
- `user_id` - Required for workspace access
- `workspace_id` - Optional; enables workspace features
- `session_id` - Optional; for session scoping in CoPilot
---
## Responsibility Boundaries
### Virus Scanning
| Component | Scans? | Notes |
|-----------|--------|-------|
| `store_media_file()` | ✅ Yes | Scans **all** content before writing to local disk |
| `WorkspaceManager.write_file()` | ✅ Yes | Scans content before persisting |
**Scanning happens at:**
1. `store_media_file()` — scans everything it downloads/decodes
2. `WorkspaceManager.write_file()` — scans before persistence
Tools like `WriteWorkspaceFileTool` don't need to scan because `WorkspaceManager.write_file()` handles it.
### Persistence
| Component | Persists To | Lifecycle |
|-----------|-------------|-----------|
| `store_media_file()` | Temp dir (`/tmp/exec_file/{exec_id}/`) | Cleaned after execution |
| `WorkspaceManager` | GCS or local storage + DB | Persistent until deleted |
**Automatic cleanup:** `clean_exec_files(graph_exec_id)` removes temp files after execution completes.
---
## Decision Tree: WorkspaceManager vs store_media_file
```text
┌─────────────────────────────────────────────────────┐
│ What do you need to do with the file? │
└─────────────────────────────────────────────────────┘
┌─────────────┴─────────────┐
▼ ▼
Process in a block Store for user access
(ffmpeg, PIL, etc.) (CoPilot files, uploads)
│ │
▼ ▼
store_media_file() WorkspaceManager
with appropriate
return_format
┌──────┴──────┐
▼ ▼
"for_local_ "for_block_
processing" output"
│ │
▼ ▼
Get local Auto-saves to
path for workspace in
tools CoPilot context
Store for user access
├── write_file() ─── Upload + persist (scans internally)
├── read_file() / get_download_url() ─── Retrieve
└── list_files() / delete_file() ─── Manage
```
### Quick Reference
| Scenario | Use |
|----------|-----|
| Block needs to process a file with ffmpeg | `store_media_file(..., return_format="for_local_processing")` |
| Block needs to send file to external API | `store_media_file(..., return_format="for_external_api")` |
| Block returning a generated file | `store_media_file(..., return_format="for_block_output")` |
| API endpoint handling file upload | `WorkspaceManager.write_file()` (handles virus scanning internally) |
| API endpoint serving file download | `WorkspaceManager.get_download_url()` |
| Listing user's files | `WorkspaceManager.list_files()` |
---
## Key Files Reference
| File | Purpose |
|------|---------|
| `backend/data/workspace.py` | Database CRUD operations for UserWorkspace and UserWorkspaceFile |
| `backend/util/workspace.py` | `WorkspaceManager` class - high-level workspace API |
| `backend/util/workspace_storage.py` | Storage backends (GCS, local) and `WorkspaceStorageBackend` interface |
| `backend/util/file.py` | `store_media_file()` and media processing utilities |
| `backend/util/virus_scanner.py` | `VirusScannerService` and `scan_content_safe()` |
| `schema.prisma` | Database model definitions |
---
## Common Patterns
### Block Processing a User's File
```python
async def run(self, input_data, *, execution_context, **kwargs):
# Normalize input to local path
local_path = await store_media_file(
file=input_data.video,
execution_context=execution_context,
return_format="for_local_processing",
)
# Process with local tools
output_path = process_video(local_path)
# Return (auto-saves to workspace in CoPilot)
result = await store_media_file(
file=output_path,
execution_context=execution_context,
return_format="for_block_output",
)
yield "output", result
```
### API Upload Endpoint
```python
from backend.util.virus_scanner import VirusDetectedError, VirusScanError
async def upload_file(file: UploadFile, user_id: str, workspace_id: str):
content = await file.read()
# write_file handles virus scanning internally
manager = WorkspaceManager(user_id, workspace_id)
try:
workspace_file = await manager.write_file(
content=content,
filename=file.filename,
)
except VirusDetectedError:
raise HTTPException(status_code=400, detail="File rejected: virus detected")
except VirusScanError:
raise HTTPException(status_code=503, detail="Virus scanning unavailable")
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
return {"file_id": workspace_file.id}
```
---
## Configuration
| Setting | Purpose | Default |
|---------|---------|---------|
| `media_gcs_bucket_name` | GCS bucket for workspace storage | None (uses local) |
| `workspace_storage_dir` | Local storage directory | `{app_data}/workspaces` |
| `max_file_size_mb` | Maximum file size in MB | 100 |
| `clamav_service_enabled` | Enable virus scanning | true |
| `clamav_service_host` | ClamAV daemon host | localhost |
| `clamav_service_port` | ClamAV daemon port | 3310 |
| `clamav_max_concurrency` | Max concurrent scans to ClamAV daemon | 5 |
| `clamav_mark_failed_scans_as_clean` | If true, scan failures pass content through instead of rejecting (⚠️ security risk if ClamAV is unreachable) | false |