mirror of
https://github.com/langgenius/dify.git
synced 2026-07-02 11:47:25 +08:00
Compare commits
3 Commits
codex/agen
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
| 15225e5825 | |||
| 9d3ac1b7b3 | |||
| 7a111c2226 |
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
run: echo "autofix.ci updates pull request branches, not merge group refs."
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Check Docker Compose inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
@ -61,7 +61,7 @@ jobs:
|
||||
dify-agent/pyproject.toml
|
||||
dify-agent/uv.lock
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
|
||||
12
.github/workflows/cli-e2e.yml
vendored
12
.github/workflows/cli-e2e.yml
vendored
@ -79,7 +79,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -123,7 +123,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -170,7 +170,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -233,7 +233,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -295,7 +295,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -351,7 +351,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/cli-edge.yml
vendored
2
.github/workflows/cli-edge.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
4
.github/workflows/cli-release.yml
vendored
4
.github/workflows/cli-release.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -98,7 +98,7 @@ jobs:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout cli ref
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/cli-tests.yml
vendored
2
.github/workflows/cli-tests.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/db-migration-test.yml
vendored
4
.github/workflows/db-migration-test.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/hotfix-cherry-pick.yml
vendored
2
.github/workflows/hotfix-cherry-pick.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
2
.github/workflows/pyrefly-diff.yml
vendored
2
.github/workflows/pyrefly-diff.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
||||
steps:
|
||||
- name: Checkout default branch (trusted code)
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
2
.github/workflows/pyrefly-type-coverage.yml
vendored
2
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
14
.github/workflows/style.yml
vendored
14
.github/workflows/style.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -124,7 +124,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -153,7 +153,7 @@ jobs:
|
||||
- name: Restore ESLint cache
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
id: eslint-cache-restore
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .eslintcache
|
||||
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
|
||||
@ -170,7 +170,7 @@ jobs:
|
||||
|
||||
- name: Save ESLint cache
|
||||
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: .eslintcache
|
||||
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
|
||||
@ -181,7 +181,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -199,7 +199,7 @@ jobs:
|
||||
.editorconfig
|
||||
|
||||
- name: Super-linter
|
||||
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
|
||||
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
BASH_SEVERITY: warning
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
working-directory: sdks/nodejs-client
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/translate-i18n-claude.yml
vendored
4
.github/workflows/translate-i18n-claude.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
|
||||
uses: anthropics/claude-code-action@a92e7c70a4da9793dc164451d829089dc057a464 # v1.0.159
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/trigger-i18n-sync.yml
vendored
2
.github/workflows/trigger-i18n-sync.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
8
.github/workflows/web-tests.yml
vendored
8
.github/workflows/web-tests.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@ -96,10 +96,6 @@ def _validate_composer_payload_for_strategy(payload: ComposerSavePayload) -> Non
|
||||
ComposerConfigValidator.validate_draft_save_payload(payload)
|
||||
|
||||
|
||||
def _agent_soul_config_json(agent_soul: AgentSoulConfig | dict[str, Any]) -> dict[str, Any]:
|
||||
return AgentSoulConfig.model_validate(agent_soul).model_dump(mode="json")
|
||||
|
||||
|
||||
class AgentComposerService:
|
||||
@classmethod
|
||||
def load_workflow_composer(
|
||||
@ -423,11 +419,7 @@ class AgentComposerService:
|
||||
account_id_for_audit=account_id,
|
||||
)
|
||||
agent.updated_by = account_id
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=payload.agent_soul,
|
||||
)
|
||||
agent.active_config_is_published = False
|
||||
|
||||
db.session.commit()
|
||||
state = cls.load_agent_composer(tenant_id=tenant_id, agent_id=agent.id)
|
||||
@ -438,54 +430,6 @@ class AgentComposerService:
|
||||
)
|
||||
return state
|
||||
|
||||
@classmethod
|
||||
def _agent_soul_matches_active_config(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
agent: Agent,
|
||||
agent_soul: AgentSoulConfig,
|
||||
) -> bool:
|
||||
if not agent.active_config_snapshot_id:
|
||||
return False
|
||||
|
||||
active_version = cls._get_version_if_present(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
version_id=agent.active_config_snapshot_id,
|
||||
)
|
||||
if not active_version:
|
||||
return False
|
||||
if agent.source == AgentSource.AGENT_APP and not cls._has_publish_visible_revision(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=agent.id,
|
||||
snapshot_id=agent.active_config_snapshot_id,
|
||||
):
|
||||
return False
|
||||
|
||||
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
|
||||
|
||||
@classmethod
|
||||
def _has_publish_visible_revision(cls, *, tenant_id: str, agent_id: str, snapshot_id: str) -> bool:
|
||||
revisions = db.session.scalars(
|
||||
select(AgentConfigRevision.operation).where(
|
||||
AgentConfigRevision.tenant_id == tenant_id,
|
||||
AgentConfigRevision.agent_id == agent_id,
|
||||
AgentConfigRevision.current_snapshot_id == snapshot_id,
|
||||
)
|
||||
).all()
|
||||
|
||||
return any(
|
||||
operation
|
||||
in {
|
||||
AgentConfigRevisionOperation.PUBLISH_DRAFT,
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
for operation in revisions
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def publish_agent_app_draft(
|
||||
cls, *, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
|
||||
@ -613,21 +557,16 @@ class AgentComposerService:
|
||||
)
|
||||
if build_draft is None:
|
||||
raise AgentVersionNotFoundError()
|
||||
applied_agent_soul = AgentSoulConfig.model_validate(build_draft.config_snapshot_dict)
|
||||
normal_draft = cls._save_agent_draft(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
agent_soul=applied_agent_soul,
|
||||
agent_soul=AgentSoulConfig.model_validate(build_draft.config_snapshot_dict),
|
||||
account_id_for_audit=account_id,
|
||||
base_snapshot_id=build_draft.base_snapshot_id,
|
||||
)
|
||||
agent.active_config_is_published = cls._agent_soul_matches_active_config(
|
||||
tenant_id=tenant_id,
|
||||
agent=agent,
|
||||
agent_soul=applied_agent_soul,
|
||||
)
|
||||
agent.active_config_is_published = False
|
||||
agent.updated_by = account_id
|
||||
db.session.delete(build_draft)
|
||||
db.session.commit()
|
||||
|
||||
@ -992,10 +992,9 @@ class AgentRosterService:
|
||||
def load_active_config_is_published_by_agent_id(self, *, tenant_id: str, agents: list[Agent]) -> dict[str, bool]:
|
||||
"""Return each Agent's stored normal-draft publish state.
|
||||
|
||||
The flag is maintained by write paths against the normal shared draft:
|
||||
saves compare the draft content with the active snapshot, while publish
|
||||
and version creation paths mark the new active snapshot clean.
|
||||
User-scoped debug drafts intentionally do not affect this state.
|
||||
The flag is maintained by write paths: normal shared draft writes mark it
|
||||
dirty, while publish/version creation paths mark it clean. User-scoped
|
||||
debug drafts intentionally do not affect this state.
|
||||
"""
|
||||
agents = [agent for agent in agents if agent.id]
|
||||
if not agents:
|
||||
|
||||
@ -437,12 +437,10 @@ def test_save_agent_app_composer_rejects_version_save_strategy():
|
||||
def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent])
|
||||
saved = {}
|
||||
|
||||
@ -453,7 +451,6 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
"_save_agent_draft",
|
||||
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
@ -476,43 +473,6 @@ def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.Monkey
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = _agent_soul_with_model()
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=False,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=agent_soul.model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent], scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_save_agent_draft",
|
||||
lambda **_kwargs: SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"agent_soul": agent_soul.model_dump(mode="json"),
|
||||
}
|
||||
)
|
||||
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
|
||||
)
|
||||
|
||||
assert agent.active_config_is_published is True
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
@ -605,11 +565,7 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
|
||||
assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict
|
||||
assert fake_session.commits == 1
|
||||
|
||||
active_version = SimpleNamespace(config_snapshot_dict=build_draft.config_snapshot_dict)
|
||||
fake_session = FakeSession(
|
||||
scalar=[agent, build_draft, normal_draft, active_version],
|
||||
scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]],
|
||||
)
|
||||
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
applied = AgentComposerService.apply_agent_app_build_draft(
|
||||
@ -620,62 +576,6 @@ def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft(monkey
|
||||
|
||||
assert applied["result"] == "success"
|
||||
assert applied["draft"]["id"] == normal_draft.id
|
||||
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
|
||||
assert agent.active_config_is_published is True
|
||||
assert fake_session.deleted == [build_draft]
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Iris",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
active_agent_soul = _agent_soul_with_model()
|
||||
build_agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
**active_agent_soul.model_dump(mode="json"),
|
||||
"prompt": {
|
||||
"system_prompt": "Build draft prompt",
|
||||
},
|
||||
}
|
||||
)
|
||||
build_draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
||||
account_id="account-1",
|
||||
draft_owner_key="account-1",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=build_agent_soul,
|
||||
)
|
||||
normal_draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
account_id=None,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=active_agent_soul,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=active_agent_soul.model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent, build_draft, normal_draft, active_version])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
|
||||
AgentComposerService.apply_agent_app_build_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict
|
||||
assert agent.active_config_is_published is False
|
||||
assert fake_session.deleted == [build_draft]
|
||||
@ -1711,52 +1611,6 @@ def test_composer_create_roster_agent_raises_when_backing_agent_missing(monkeypa
|
||||
)
|
||||
|
||||
|
||||
def test_agent_app_draft_match_does_not_mark_create_version_as_published(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = AgentSoulConfig()
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
|
||||
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.CREATE_VERSION]])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
|
||||
|
||||
assert (
|
||||
AgentComposerService._agent_soul_matches_active_config(
|
||||
tenant_id="tenant-1",
|
||||
agent=agent,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
def test_agent_app_draft_match_marks_publish_visible_revision_as_published(monkeypatch: pytest.MonkeyPatch):
|
||||
agent_soul = AgentSoulConfig()
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict=agent_soul)
|
||||
fake_session = FakeSession(scalars=[[AgentConfigRevisionOperation.PUBLISH_DRAFT]])
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot)
|
||||
|
||||
assert (
|
||||
AgentComposerService._agent_soul_matches_active_config(
|
||||
tenant_id="tenant-1",
|
||||
agent=agent,
|
||||
agent_soul=agent_soul,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
|
||||
def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch):
|
||||
fake_session = FakeSession(
|
||||
scalar=[
|
||||
|
||||
41
dify-agent/src/dify_agent/adapters/shell/__init__.py
Normal file
41
dify-agent/src/dify_agent/adapters/shell/__init__.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Provider-agnostic shell provisioning/execution adapter for the Dify agent.
|
||||
|
||||
The boundary protocols live in ``protocols``; the default shellctl backend in
|
||||
``shellctl``; and env-var-driven provider selection in ``config``/``factory``.
|
||||
``create_shell_provisioner`` is the recommended entry point for callers.
|
||||
"""
|
||||
|
||||
from dify_agent.adapters.shell.config import DEFAULT_SHELL_PROVIDER, ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provisioner
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellEnvironmentDescriptor,
|
||||
ShellExecutionResult,
|
||||
ShellExecutorProtocol,
|
||||
ShellFileTransferProtocol,
|
||||
ShellHandle,
|
||||
ShellProvisionProtocol,
|
||||
)
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlProvisioner,
|
||||
ShellFileTransferError,
|
||||
ShellProvisionError,
|
||||
create_default_shellctl_client_factory,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_SHELL_PROVIDER",
|
||||
"ShellAdapterSettings",
|
||||
"ShellEnvironmentDescriptor",
|
||||
"ShellExecutionResult",
|
||||
"ShellExecutorProtocol",
|
||||
"ShellFileTransferError",
|
||||
"ShellFileTransferProtocol",
|
||||
"ShellHandle",
|
||||
"ShellProvisionError",
|
||||
"ShellProvisionProtocol",
|
||||
"ShellctlEnvironmentDescriptor",
|
||||
"ShellctlProvisioner",
|
||||
"create_default_shellctl_client_factory",
|
||||
"create_shell_provisioner",
|
||||
]
|
||||
29
dify-agent/src/dify_agent/adapters/shell/config.py
Normal file
29
dify-agent/src/dify_agent/adapters/shell/config.py
Normal file
@ -0,0 +1,29 @@
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
DEFAULT_SHELL_PROVIDER = "shellctl"
|
||||
|
||||
|
||||
class ShellAdapterSettings(BaseSettings):
|
||||
"""Env-backed settings used to construct a shell provisioner.
|
||||
|
||||
``shellctl_auth_token`` defaults to ``None``; the factory forwards an empty
|
||||
string to the shellctl client so it does not fall back to ambient process
|
||||
credentials. Deployments that enable shellctl bearer auth must set
|
||||
``DIFY_AGENT_SHELLCTL_AUTH_TOKEN`` explicitly.
|
||||
"""
|
||||
|
||||
shell_provider: str = DEFAULT_SHELL_PROVIDER
|
||||
shellctl_entrypoint: str | None = None
|
||||
shellctl_auth_token: str | None = None
|
||||
|
||||
model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict(
|
||||
env_prefix="DIFY_AGENT_",
|
||||
env_file=(".env", "dify-agent/.env"),
|
||||
extra="ignore",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["DEFAULT_SHELL_PROVIDER", "ShellAdapterSettings"]
|
||||
36
dify-agent/src/dify_agent/adapters/shell/factory.py
Normal file
36
dify-agent/src/dify_agent/adapters/shell/factory.py
Normal file
@ -0,0 +1,36 @@
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlProvisioner,
|
||||
create_default_shellctl_client_factory,
|
||||
)
|
||||
|
||||
|
||||
def create_shell_provisioner(
|
||||
settings: ShellAdapterSettings | None = None,
|
||||
) -> ShellProvisionProtocol[ShellctlEnvironmentDescriptor]:
|
||||
"""Return the shell provisioner selected by ``DIFY_AGENT_SHELL_PROVIDER``.
|
||||
|
||||
Raises:
|
||||
ValueError: if the provider name is unknown, or if the ``shellctl``
|
||||
provider is selected without a non-empty ``DIFY_AGENT_SHELLCTL_ENTRYPOINT``.
|
||||
"""
|
||||
resolved = settings or ShellAdapterSettings()
|
||||
provider = resolved.shell_provider.strip().lower()
|
||||
match provider:
|
||||
case "shellctl":
|
||||
entrypoint = (resolved.shellctl_entrypoint or "").strip()
|
||||
if not entrypoint:
|
||||
raise ValueError("DIFY_AGENT_SHELLCTL_ENTRYPOINT is required for the 'shellctl' shell provider.")
|
||||
return ShellctlProvisioner(
|
||||
client_factory=create_default_shellctl_client_factory(
|
||||
entrypoint=entrypoint,
|
||||
token=resolved.shellctl_auth_token or "",
|
||||
),
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unknown shell provider: {resolved.shell_provider!r}.")
|
||||
|
||||
|
||||
__all__ = ["create_shell_provisioner"]
|
||||
128
dify-agent/src/dify_agent/adapters/shell/protocols.py
Normal file
128
dify-agent/src/dify_agent/adapters/shell/protocols.py
Normal file
@ -0,0 +1,128 @@
|
||||
from typing import Protocol, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, model_validator
|
||||
from typing_extensions import Self
|
||||
|
||||
|
||||
class ShellEnvironmentDescriptor(BaseModel):
|
||||
"""Minimal, serializable seed used to re-derive a provisioned environment.
|
||||
|
||||
Holds only the provider-agnostic identity needed to reattach to an existing
|
||||
shell environment across a snapshot/resume cycle — never live resources
|
||||
(clients, handles, executors). Callers persist this in their snapshot and
|
||||
pass it back to ``ShellProvisionProtocol.reattach`` to reconstruct an
|
||||
equivalent ``ShellHandle`` without allocating a new environment.
|
||||
|
||||
Each provider defines its own concrete subclass carrying the fields it needs
|
||||
to reattach (e.g. workspace path + session id for shellctl). Validation runs
|
||||
at instantiation time via Pydantic so a malicious or corrupt snapshot cannot
|
||||
escape the workspace root or inject shell syntax into lifecycle commands,
|
||||
even if a future caller uses the provider directly.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _run_validate(self) -> Self:
|
||||
self.validate()
|
||||
return self
|
||||
|
||||
def validate(self) -> None:
|
||||
"""validate the correctness of the object.
|
||||
|
||||
Advanced validations that requires remote procedure calls,
|
||||
for example access control, quota checks, should be implemented in
|
||||
provision and reattach.
|
||||
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
DescriptorT = TypeVar("DescriptorT", bound=ShellEnvironmentDescriptor)
|
||||
|
||||
|
||||
class ShellExecutionResult(Protocol):
|
||||
"""Completed shell command result.
|
||||
|
||||
``stdout``/``stderr``/``exit_code`` are reserved fields. Backends that
|
||||
cannot distinguish a stream return an empty string for it, and return
|
||||
``None`` from ``exit_code()`` when no exit status is available.
|
||||
"""
|
||||
|
||||
def stdout(self) -> str: ...
|
||||
|
||||
def stderr(self) -> str: ...
|
||||
|
||||
def exit_code(self) -> int | None: ...
|
||||
|
||||
def truncated(self) -> bool: ...
|
||||
|
||||
|
||||
class ShellExecutorProtocol(Protocol):
|
||||
"""Runs commands inside an already-provisioned shell environment.
|
||||
|
||||
``execute`` drains the command to completion before returning — there is no
|
||||
separate ``wait`` step. This suits the current server-side callers (sandbox
|
||||
file helpers, workspace bootstrap) that always run a script to completion.
|
||||
|
||||
If a future use case needs to start a command, interact with its stdin, or
|
||||
interrupt it before completion, split this protocol into ``execute`` →
|
||||
``ShellExecutionHandle`` plus ``wait`` / ``input`` / ``interrupt`` optional
|
||||
capabilities, mirroring the shape that was prototyped here before
|
||||
simplification.
|
||||
"""
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> ShellExecutionResult: ...
|
||||
|
||||
|
||||
class ShellFileTransferProtocol(Protocol):
|
||||
"""Moves file bytes between the caller and a provisioned shell environment.
|
||||
|
||||
``remote_path`` is interpreted by the backend relative to the provisioned
|
||||
environment (for the shellctl backend, the session workspace). Higher-level
|
||||
Dify/skill transfers are layered on top of this primitive, not implemented
|
||||
here. Implementations raise on transfer failure (missing path, decode error,
|
||||
or a non-zero transfer command).
|
||||
"""
|
||||
|
||||
async def upload(self, *, content: bytes, remote_path: str) -> None: ...
|
||||
|
||||
async def download(self, *, remote_path: str) -> bytes: ...
|
||||
|
||||
|
||||
# pyrefly: ignore [variance-mismatch]
|
||||
# intended to be invariant
|
||||
class ShellHandle(Protocol[DescriptorT]):
|
||||
"""Live reference to one provisioned shell environment.
|
||||
|
||||
The handle itself is not serialized. ``descriptor()`` returns the minimal
|
||||
seed needed to reconstruct an equivalent handle after a snapshot/resume.
|
||||
"""
|
||||
|
||||
def descriptor(self) -> DescriptorT: ...
|
||||
|
||||
async def get_executor(self) -> ShellExecutorProtocol: ...
|
||||
|
||||
async def get_file_transfer(self) -> ShellFileTransferProtocol: ...
|
||||
|
||||
|
||||
class ShellProvisionProtocol(Protocol[DescriptorT]):
|
||||
"""Creates, reattaches to, and destroys shell environments.
|
||||
|
||||
``provision`` allocates a fresh environment; ``reattach`` rebuilds a live
|
||||
handle for an environment that already exists (from a persisted descriptor)
|
||||
without allocating a new one, so resumed runs can keep executing and
|
||||
eventually clean up. ``destroy`` tears an environment down.
|
||||
"""
|
||||
|
||||
async def provision(self) -> ShellHandle[DescriptorT]: ...
|
||||
|
||||
async def reattach(self, descriptor: DescriptorT) -> ShellHandle[DescriptorT]: ...
|
||||
|
||||
async def destroy(self, handle: ShellHandle[DescriptorT]) -> None: ...
|
||||
486
dify-agent/src/dify_agent/adapters/shell/shellctl.py
Normal file
486
dify-agent/src/dify_agent/adapters/shell/shellctl.py
Normal file
@ -0,0 +1,486 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
from dify_agent.adapters.shell.protocols import ShellEnvironmentDescriptor, ShellHandle
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKSPACE_ROOT = "~/workspace"
|
||||
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
_SESSION_ID_PATTERN = re.compile(r"[0-9a-f]{7,16}")
|
||||
# Drains at most this many shellctl output windows per wait so a stuck or
|
||||
# pathologically chatty job cannot loop forever inside one wait() call.
|
||||
_MAX_OUTPUT_WINDOWS = 64
|
||||
_DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
|
||||
_FILE_TRANSFER_TIMEOUT_SECONDS = 60.0
|
||||
# Sentinels frame base64 download payloads so prompt/tmux noise around the
|
||||
# shellctl merged output stream can be stripped before decoding.
|
||||
_TRANSFER_BEGIN = "<<<DIFY_SHELL_FILE_BEGIN>>>"
|
||||
_TRANSFER_END = "<<<DIFY_SHELL_FILE_END>>>"
|
||||
_DOWNLOAD_MISSING_EXIT_CODE = 66
|
||||
|
||||
|
||||
class ShellProvisionError(RuntimeError):
|
||||
"""Raised when a shell environment cannot be provisioned."""
|
||||
|
||||
|
||||
class ShellFileTransferError(RuntimeError):
|
||||
"""Raised when a file cannot be uploaded to or downloaded from the workspace."""
|
||||
|
||||
|
||||
class ShellctlEnvironmentDescriptor(ShellEnvironmentDescriptor):
|
||||
"""Shellctl-specific descriptor carrying the workspace path and session id."""
|
||||
|
||||
workspace_cwd: str
|
||||
session_id: str
|
||||
|
||||
def validate(self) -> None:
|
||||
if not _SESSION_ID_PATTERN.fullmatch(self.session_id):
|
||||
raise ValueError(f"Invalid session_id in reattach descriptor: {self.session_id!r}.")
|
||||
expected_workspace = f"{_WORKSPACE_ROOT}/{self.session_id}"
|
||||
if self.workspace_cwd != expected_workspace:
|
||||
raise ValueError(
|
||||
f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}, "
|
||||
f"got {self.workspace_cwd!r}."
|
||||
)
|
||||
|
||||
|
||||
class ShellctlJobResult(Protocol):
|
||||
"""Structural shape of one shellctl job result the adapter relies on.
|
||||
|
||||
Mirrors the fields the adapter reads from ``shell_session_manager`` job
|
||||
results without importing the concrete type, so the merged output stream,
|
||||
paging offset, completion flag, and exit status stay duck-typed.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
done: bool
|
||||
output: str
|
||||
offset: int
|
||||
truncated: bool
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
class ShellctlJobStatus(Protocol):
|
||||
"""Structural shape of one shellctl status-only result (no output stream).
|
||||
|
||||
Returned by ``terminate``; carries completion and exit status plus the
|
||||
latest paging offset so the adapter can drain any remaining output.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
done: bool
|
||||
offset: int
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
class ShellctlClientProtocol(Protocol):
|
||||
"""Boundary the shellctl adapter needs from a shell-session-manager client."""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> ShellctlJobResult: ...
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> ShellctlJobResult: ...
|
||||
|
||||
async def input(
|
||||
self,
|
||||
job_id: str,
|
||||
text: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> ShellctlJobResult: ...
|
||||
|
||||
async def terminate(
|
||||
self,
|
||||
job_id: str,
|
||||
grace_seconds: float = _DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
) -> ShellctlJobStatus: ...
|
||||
|
||||
async def delete(self, job_id: str, *, force: bool = False) -> object: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
type ShellctlClientFactory = Callable[[], ShellctlClientProtocol]
|
||||
|
||||
|
||||
class ShellctlExecutionResult:
|
||||
"""Completed shellctl command result.
|
||||
|
||||
shellctl merges stderr into a single output stream, so ``stderr()`` is
|
||||
always empty and the merged stream is reported as ``stdout()``.
|
||||
"""
|
||||
|
||||
_stdout: str
|
||||
_stderr: str
|
||||
_exit_code: int | None
|
||||
_truncated: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
stdout: str,
|
||||
stderr: str = "",
|
||||
exit_code: int | None,
|
||||
truncated: bool = False,
|
||||
) -> None:
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self._exit_code = exit_code
|
||||
self._truncated = truncated
|
||||
|
||||
def stdout(self) -> str:
|
||||
return self._stdout
|
||||
|
||||
def stderr(self) -> str:
|
||||
return self._stderr
|
||||
|
||||
def exit_code(self) -> int | None:
|
||||
return self._exit_code
|
||||
|
||||
def truncated(self) -> bool:
|
||||
"""Whether the returned ``stdout`` may be incomplete.
|
||||
|
||||
``True`` means shellctl still reported more output past what was
|
||||
captured when draining stopped (its per-window ``truncated`` flag on the
|
||||
final window, e.g. the output-window cap was hit). Callers that need the
|
||||
command's *entire* output must treat a truncated result as a failure or
|
||||
re-read, rather than trusting ``stdout()`` as complete.
|
||||
"""
|
||||
return self._truncated
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlExecutor:
|
||||
"""Runs commands in one provisioned shellctl workspace.
|
||||
|
||||
Conforms structurally to ``ShellExecutorProtocol``. ``execute`` drains the
|
||||
command to completion (accumulating shellctl's paged output windows) and
|
||||
best-effort deletes the finished job before returning. The executor is
|
||||
single-environment and is not safe to share across workspaces.
|
||||
"""
|
||||
|
||||
client: ShellctlClientProtocol
|
||||
workspace_cwd: str
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> ShellctlExecutionResult:
|
||||
result = await self.client.run(
|
||||
command,
|
||||
cwd=cwd if cwd is not None else self.workspace_cwd,
|
||||
env=env,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
output_parts = [result.output]
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
job_id = result.job_id
|
||||
windows = 1
|
||||
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
|
||||
result = await self.client.wait(job_id, offset=offset, timeout=self.timeout)
|
||||
output_parts.append(result.output)
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
windows += 1
|
||||
if done:
|
||||
await _delete_job_best_effort(self.client, job_id)
|
||||
return ShellctlExecutionResult(
|
||||
stdout="".join(output_parts),
|
||||
exit_code=exit_code,
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlFileTransfer:
|
||||
"""Moves file bytes in and out of one provisioned shellctl workspace.
|
||||
|
||||
Conforms structurally to ``ShellFileTransferProtocol``. Transfers run as
|
||||
workspace-scoped shellctl jobs over the merged text channel: uploads embed
|
||||
base64 in the command and pipe it through ``base64 -d``; downloads emit the
|
||||
file's base64 framed by sentinels so prompt/tmux noise can be stripped
|
||||
before decoding. Because the encoded payload is embedded in the upload
|
||||
command, very large files can exceed the shell argument limit; this
|
||||
primitive targets ordinary control-plane file sizes, not bulk binary
|
||||
transfer.
|
||||
"""
|
||||
|
||||
client: ShellctlClientProtocol
|
||||
workspace_cwd: str
|
||||
timeout: float = _FILE_TRANSFER_TIMEOUT_SECONDS
|
||||
|
||||
async def upload(self, *, content: bytes, remote_path: str) -> None:
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
completed = await _run_to_completion(
|
||||
self.client,
|
||||
_upload_script(remote_path=remote_path, encoded=encoded),
|
||||
cwd=self.workspace_cwd,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if completed.exit_code != 0:
|
||||
raise ShellFileTransferError(
|
||||
f"Failed to upload to {remote_path!r}: exit_code={completed.exit_code}, "
|
||||
f"output={_output_tail(completed.output)!r}"
|
||||
)
|
||||
|
||||
async def download(self, *, remote_path: str) -> bytes:
|
||||
completed = await _run_to_completion(
|
||||
self.client,
|
||||
_download_script(remote_path=remote_path),
|
||||
cwd=self.workspace_cwd,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if completed.exit_code == _DOWNLOAD_MISSING_EXIT_CODE:
|
||||
raise ShellFileTransferError(f"File not found in workspace: {remote_path!r}.")
|
||||
if completed.exit_code != 0:
|
||||
raise ShellFileTransferError(
|
||||
f"Failed to download {remote_path!r}: exit_code={completed.exit_code}, "
|
||||
f"output={_output_tail(completed.output)!r}"
|
||||
)
|
||||
framed = _extract_framed_payload(completed.output)
|
||||
try:
|
||||
return base64.b64decode("".join(framed.split()), validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ShellFileTransferError(f"Failed to decode downloaded file {remote_path!r}: {exc}") from exc
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlHandle:
|
||||
"""Live reference to one provisioned shellctl workspace.
|
||||
|
||||
Conforms structurally to ``ShellHandle``. Owns the shellctl ``client`` and
|
||||
the allocated ``workspace_cwd`` until the provisioner destroys it.
|
||||
``get_executor`` returns a fresh executor bound to this workspace each call.
|
||||
"""
|
||||
|
||||
client: ShellctlClientProtocol
|
||||
workspace_cwd: str
|
||||
session_id: str
|
||||
|
||||
def descriptor(self) -> ShellctlEnvironmentDescriptor:
|
||||
return ShellctlEnvironmentDescriptor(workspace_cwd=self.workspace_cwd, session_id=self.session_id)
|
||||
|
||||
async def get_executor(self) -> ShellctlExecutor:
|
||||
return ShellctlExecutor(client=self.client, workspace_cwd=self.workspace_cwd)
|
||||
|
||||
async def get_file_transfer(self) -> ShellctlFileTransfer:
|
||||
return ShellctlFileTransfer(client=self.client, workspace_cwd=self.workspace_cwd)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ShellctlProvisioner:
|
||||
"""Provisions isolated shellctl workspaces, one client per environment.
|
||||
|
||||
Conforms structurally to ``ShellProvisionProtocol``.
|
||||
"""
|
||||
|
||||
client_factory: ShellctlClientFactory
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
async def provision(self) -> ShellctlHandle:
|
||||
client = self.client_factory()
|
||||
session_id = _generate_session_id()
|
||||
workspace_cwd = f"{_WORKSPACE_ROOT}/{session_id}"
|
||||
try:
|
||||
completed = await _run_to_completion(client, _mkdir_script(session_id), cwd=None, timeout=self.timeout)
|
||||
except BaseException:
|
||||
await client.close()
|
||||
raise
|
||||
if completed.exit_code != 0:
|
||||
await client.close()
|
||||
raise ShellProvisionError(
|
||||
f"Failed to create shell workspace {workspace_cwd}: mkdir exited with code {completed.exit_code}."
|
||||
)
|
||||
return ShellctlHandle(client=client, workspace_cwd=workspace_cwd, session_id=session_id)
|
||||
|
||||
async def reattach(self, descriptor: ShellctlEnvironmentDescriptor) -> ShellctlHandle:
|
||||
"""Rebuild a live handle for an existing workspace without re-allocating it.
|
||||
|
||||
Opens a fresh shellctl client and points it at the workspace recorded in
|
||||
``descriptor``. No ``mkdir`` is issued: the workspace is assumed to still
|
||||
exist from the original ``provision``. Used on snapshot resume so a run
|
||||
can keep executing in and eventually clean up its prior workspace.
|
||||
"""
|
||||
client = self.client_factory()
|
||||
return ShellctlHandle(
|
||||
client=client,
|
||||
workspace_cwd=descriptor.workspace_cwd,
|
||||
session_id=descriptor.session_id,
|
||||
)
|
||||
|
||||
async def destroy(self, handle: ShellHandle[ShellctlEnvironmentDescriptor]) -> None:
|
||||
if not isinstance(handle, ShellctlHandle):
|
||||
raise TypeError("ShellctlProvisioner can only destroy handles it provisioned.")
|
||||
try:
|
||||
completed = await _run_to_completion(
|
||||
handle.client, _cleanup_script(handle.session_id), cwd=None, timeout=self.timeout
|
||||
)
|
||||
if completed.exit_code != 0:
|
||||
logger.warning(
|
||||
"Shell workspace cleanup for session %s exited with code %s.",
|
||||
handle.session_id,
|
||||
completed.exit_code,
|
||||
)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
logger.warning("Failed to remove shell workspace for session %s: %s", handle.session_id, exc)
|
||||
finally:
|
||||
await handle.client.close()
|
||||
|
||||
|
||||
def create_default_shellctl_client_factory(*, entrypoint: str, token: str) -> ShellctlClientFactory:
|
||||
"""Return a factory that builds a real shell-session-manager shellctl client.
|
||||
|
||||
The concrete client is imported lazily so importing this module does not
|
||||
require the private ``shell-session-manager`` package. An explicit empty
|
||||
``token`` is forwarded as-is to avoid the client falling back to ambient
|
||||
process credentials.
|
||||
"""
|
||||
|
||||
def factory() -> ShellctlClientProtocol:
|
||||
from shell_session_manager.shellctl.client import ShellctlClient
|
||||
|
||||
return ShellctlClient(entrypoint, token=token)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _CompletedJob:
|
||||
"""Drained result of one internal shellctl job: merged output plus exit code."""
|
||||
|
||||
output: str
|
||||
exit_code: int | None
|
||||
|
||||
|
||||
async def _run_to_completion(
|
||||
client: ShellctlClientProtocol,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None,
|
||||
timeout: float,
|
||||
) -> _CompletedJob:
|
||||
"""Run one internal lifecycle script to completion, returning output and exit code."""
|
||||
result = await client.run(script, cwd=cwd, env=None, timeout=timeout)
|
||||
output_parts = [result.output]
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
job_id = result.job_id
|
||||
windows = 1
|
||||
while (not done or truncated) and windows < _MAX_OUTPUT_WINDOWS:
|
||||
result = await client.wait(job_id, offset=offset, timeout=timeout)
|
||||
output_parts.append(result.output)
|
||||
done = result.done
|
||||
truncated = result.truncated
|
||||
offset = result.offset
|
||||
exit_code = result.exit_code
|
||||
windows += 1
|
||||
if done:
|
||||
await _delete_job_best_effort(client, job_id)
|
||||
return _CompletedJob(output="".join(output_parts), exit_code=exit_code)
|
||||
|
||||
|
||||
async def _delete_job_best_effort(client: ShellctlClientProtocol, job_id: str) -> None:
|
||||
"""Force-delete one shellctl job, never failing the caller on cleanup errors."""
|
||||
try:
|
||||
_ = await client.delete(job_id, force=True)
|
||||
except Exception as exc: # noqa: BLE001 - best-effort teardown must not surface cleanup errors
|
||||
logger.warning("Failed to delete shellctl job %s: %s", job_id, exc)
|
||||
|
||||
|
||||
def _generate_session_id() -> str:
|
||||
"""Return a shell-safe random session id used as the workspace directory name."""
|
||||
return secrets.token_hex(8)
|
||||
|
||||
|
||||
def _mkdir_script(session_id: str) -> str:
|
||||
return f'mkdir -p "$HOME/workspace/{session_id}"'
|
||||
|
||||
|
||||
def _cleanup_script(session_id: str) -> str:
|
||||
return f'rm -rf -- "$HOME/workspace/{session_id}"'
|
||||
|
||||
|
||||
def _upload_script(*, remote_path: str, encoded: str) -> str:
|
||||
"""Return a script that recreates a file from embedded base64 in the workspace."""
|
||||
quoted = _shquote(remote_path)
|
||||
return f"mkdir -p \"$(dirname -- {quoted})\" && printf %s '{encoded}' | base64 -d > {quoted}"
|
||||
|
||||
|
||||
def _download_script(*, remote_path: str) -> str:
|
||||
"""Return a script that emits a file's base64 between transfer sentinels."""
|
||||
quoted = _shquote(remote_path)
|
||||
return (
|
||||
f"if [ ! -f {quoted} ]; then exit {_DOWNLOAD_MISSING_EXIT_CODE}; fi; "
|
||||
f"printf %s {_shquote(_TRANSFER_BEGIN)}; "
|
||||
f'base64 < {quoted} | tr -d "\\n"; '
|
||||
f"printf %s {_shquote(_TRANSFER_END)}"
|
||||
)
|
||||
|
||||
|
||||
def _extract_framed_payload(output: str) -> str:
|
||||
"""Return the base64 text framed by the transfer sentinels in shellctl output."""
|
||||
begin = output.find(_TRANSFER_BEGIN)
|
||||
end = output.find(_TRANSFER_END, begin + len(_TRANSFER_BEGIN)) if begin != -1 else -1
|
||||
if begin == -1 or end == -1:
|
||||
raise ShellFileTransferError("download command returned no framed payload")
|
||||
return output[begin + len(_TRANSFER_BEGIN) : end]
|
||||
|
||||
|
||||
def _shquote(value: str) -> str:
|
||||
"""Single-quote a value for POSIX shells, escaping embedded single quotes."""
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _output_tail(output: str, *, limit: int = 500) -> str:
|
||||
"""Return the trailing slice of command output for compact error messages."""
|
||||
return output[-limit:]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ShellFileTransferError",
|
||||
"ShellProvisionError",
|
||||
"ShellctlClientFactory",
|
||||
"ShellctlClientProtocol",
|
||||
"ShellctlEnvironmentDescriptor",
|
||||
"ShellctlExecutionResult",
|
||||
"ShellctlExecutor",
|
||||
"ShellctlFileTransfer",
|
||||
"ShellctlHandle",
|
||||
"ShellctlJobResult",
|
||||
"ShellctlJobStatus",
|
||||
"ShellctlProvisioner",
|
||||
"create_default_shellctl_client_factory",
|
||||
]
|
||||
@ -1,28 +1,20 @@
|
||||
"""Shellctl-backed Dify shell layer.
|
||||
"""Shell layer backed by the shell adapter provisioner/executor mechanism.
|
||||
|
||||
``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly
|
||||
``shell_run``, ``shell_wait``, ``shell_input``, and ``shell_interrupt``. The
|
||||
layer persists only JSON-safe shell session state in ``runtime_state`` and keeps
|
||||
its live shellctl HTTP client on the layer instance only while
|
||||
its live ``ShellctlHandle`` on the layer instance only while
|
||||
``resource_context()`` is active. Agenton enters that resource scope before
|
||||
``on_context_create`` or ``on_context_resume`` and exits it after
|
||||
``on_context_suspend`` or ``on_context_delete``, so business hooks and shell
|
||||
tools can rely on a live client without ever serializing it into snapshots.
|
||||
tools can rely on live resources without ever serializing them into snapshots.
|
||||
|
||||
The runtime state tracks shellctl job ids for both user-visible shell jobs and
|
||||
internal lifecycle jobs such as workspace mkdir/cleanup commands. Those internal
|
||||
jobs are intentionally not deleted ad hoc; shellctl job-state deletion is
|
||||
centralized in ``on_context_delete`` so one lifecycle hook owns exit-time
|
||||
cleanup for successful create/resume flows. If ``on_context_create`` or a later
|
||||
side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
|
||||
Agenton still exits ``resource_context()`` but never transitions the layer to
|
||||
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
|
||||
so the enter hook itself must perform best-effort business compensation before
|
||||
re-raising the failure. Agent Soul shell env is injected into user-visible
|
||||
commands and CLI bootstrap commands without persisting a workspace env file.
|
||||
Agent Stub env injection uses shellctl's native per-run ``env`` argument for
|
||||
user-visible ``shell.run`` and for trusted server-owned fixed scripts executed
|
||||
through ``run_remote_script()``.
|
||||
The layer delegates workspace lifecycle to ``ShellProvisionProtocol``:
|
||||
``provision`` allocates a fresh workspace, ``reattach`` rebuilds a live handle
|
||||
for an existing workspace from a serialized descriptor, and ``destroy`` tears
|
||||
the workspace down. User-facing shell tools call the shellctl client obtained
|
||||
from the handle directly; trusted server-owned scripts go through
|
||||
``ShellctlExecutor`` which auto-cleans completed jobs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -32,24 +24,23 @@ from contextlib import asynccontextmanager
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, NotRequired, Protocol, TypedDict
|
||||
from typing import ClassVar, NotRequired, Protocol, TypedDict, cast
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
|
||||
from pydantic_ai import Tool
|
||||
from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError
|
||||
from shell_session_manager.shellctl.client import ShellctlClientError
|
||||
from shell_session_manager.shellctl.shared import (
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DeleteJobResponse,
|
||||
JobResult,
|
||||
JobStatusView,
|
||||
)
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import LayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
|
||||
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlEnvironmentDescriptor, ShellctlExecutor, ShellctlHandle
|
||||
from dify_agent.agent_stub.server.shell_agent_stub_env import ShellAgentStubTokenFactory, build_shell_agent_stub_env
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
@ -58,12 +49,6 @@ from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellL
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKSPACE_ROOT = "~/workspace"
|
||||
_WORKSPACE_COLLISION_EXIT_CODE = 17
|
||||
_SESSION_TIME_HEX_MASK = 0xFFFFF
|
||||
_SESSION_RANDOM_HEX_LENGTH = 2
|
||||
_SESSION_ID_ATTEMPT_LIMIT = 256
|
||||
_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$")
|
||||
_REMOTE_COMMAND_MAX_OUTPUT_WINDOWS = 64
|
||||
_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools:
|
||||
|
||||
1. shell_run
|
||||
@ -221,7 +206,7 @@ class ShellctlClientProtocol(Protocol):
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse: ...
|
||||
) -> object: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
@ -237,12 +222,9 @@ class DifyShellRuntimeState(BaseModel):
|
||||
created before suspension. Callers should replace the stored list/dict values
|
||||
rather than mutating them in place so Pydantic assignment validation keeps
|
||||
guarding the serialized state. Hydrated public snapshots must keep
|
||||
``session_id`` in the proposal's safe lowercase-hex format and must keep
|
||||
``workspace_cwd`` exactly aligned with ``~/workspace/<session_id>`` so resume
|
||||
and delete paths cannot escape the isolated workspace root or inject shell
|
||||
syntax into lifecycle commands. Shellctl job ids remain opaque strings here;
|
||||
the layer only enforces uniqueness plus the invariant that any stored offset
|
||||
entry must belong to a tracked job id in the same runtime state.
|
||||
``session_id`` and ``workspace_cwd`` consistent with the descriptor returned
|
||||
by the shell provisioner, so resume and delete paths cannot escape the
|
||||
isolated workspace root or inject shell syntax into lifecycle commands.
|
||||
"""
|
||||
|
||||
session_id: str | None = None
|
||||
@ -255,10 +237,12 @@ class DifyShellRuntimeState(BaseModel):
|
||||
@field_validator("session_id")
|
||||
@classmethod
|
||||
def validate_session_id(cls, value: str | None) -> str | None:
|
||||
"""Accept only the short lowercase-hex session ids defined by the proposal."""
|
||||
"""Reject session ids that could escape the workspace root or inject shell syntax."""
|
||||
if value is None:
|
||||
return value
|
||||
return _validated_session_id(value)
|
||||
if not re.fullmatch(r"[0-9a-f]{7,16}", value):
|
||||
raise ValueError("session_id must be 7 to 16 lowercase hex characters (got an invalid value).")
|
||||
return value
|
||||
|
||||
@field_validator("job_ids")
|
||||
@classmethod
|
||||
@ -286,24 +270,28 @@ class DifyShellRuntimeState(BaseModel):
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteCommandResult:
|
||||
"""Completed remote sandbox command returned to server-owned callers."""
|
||||
"""Completed remote sandbox command returned to server-owned callers.
|
||||
|
||||
Only fields with live consumers are kept: ``output``/``exit_code`` (read by
|
||||
every caller), ``truncated`` (drive pull treats a truncated result as a
|
||||
failure because it needs the command's full output), and ``status`` (used in
|
||||
drive's human-readable error message). shellctl paging details such as the
|
||||
job id, completion flag, byte offset, and output path are intentionally not
|
||||
surfaced here, since no caller reads them.
|
||||
"""
|
||||
|
||||
job_id: str
|
||||
status: str
|
||||
done: bool
|
||||
exit_code: int | None
|
||||
output: str
|
||||
offset: int
|
||||
truncated: bool
|
||||
output_path: str
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
|
||||
"""Shell tool layer backed by a live shellctl client while active.
|
||||
"""Shell tool layer backed by the shell provisioner/executor mechanism.
|
||||
|
||||
The mutable serializable state lives in ``runtime_state``; the live client is
|
||||
intentionally kept off-snapshot in ``_shellctl_client``. Tool methods update
|
||||
The mutable serializable state lives in ``runtime_state``; the live
|
||||
``ShellctlHandle`` is intentionally kept off-snapshot. Tool methods update
|
||||
tracked job ids and output offsets after every successful shellctl response so
|
||||
later ``shell_wait``/``shell_input`` calls can resume from the last known
|
||||
offset without exposing offsets as model-controlled inputs.
|
||||
@ -312,39 +300,35 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID
|
||||
|
||||
config: DifyShellLayerConfig
|
||||
shellctl_entrypoint: str
|
||||
shellctl_client_factory: ShellctlClientFactory
|
||||
shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor]
|
||||
agent_stub_api_base_url: str | None = None
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
_shellctl_client: ShellctlClientProtocol | None = None
|
||||
_shell_handle: ShellctlHandle | None = None
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyShellLayerConfig) -> Self:
|
||||
"""Reject construction that omits server-injected shellctl settings."""
|
||||
"""Reject construction that omits the shell provisioner."""
|
||||
del config
|
||||
raise TypeError("DifyShellLayer requires server-side shellctl settings and must use a provider factory.")
|
||||
raise TypeError("DifyShellLayer requires a shell provisioner and must use a provider factory.")
|
||||
|
||||
@classmethod
|
||||
def from_config_with_settings(
|
||||
cls,
|
||||
config: DifyShellLayerConfig,
|
||||
*,
|
||||
shellctl_entrypoint: str | None,
|
||||
shellctl_client_factory: ShellctlClientFactory,
|
||||
shell_provisioner: ShellProvisionProtocol[ShellctlEnvironmentDescriptor] | None,
|
||||
agent_stub_api_base_url: str | None = None,
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None,
|
||||
) -> Self:
|
||||
"""Create the layer from public config plus server-only shell settings."""
|
||||
normalized_entrypoint = (shellctl_entrypoint or "").strip()
|
||||
if not normalized_entrypoint:
|
||||
"""Create the layer from public config plus shell provisioner settings."""
|
||||
if shell_provisioner is None:
|
||||
raise ValueError(
|
||||
"DifyShellLayer requires a non-empty DIFY_AGENT_SHELLCTL_ENTRYPOINT when the 'dify.shell' layer is used."
|
||||
"DifyShellLayer requires a non-null shell provisioner when the 'dify.shell' layer is used."
|
||||
)
|
||||
layer = cls(
|
||||
config=config,
|
||||
shellctl_entrypoint=normalized_entrypoint,
|
||||
shellctl_client_factory=shellctl_client_factory,
|
||||
shell_provisioner=shell_provisioner,
|
||||
agent_stub_api_base_url=agent_stub_api_base_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
)
|
||||
@ -369,107 +353,90 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
"""Hold one live shellctl client for one active Agenton layer scope.
|
||||
"""Hold the live shell handle scope.
|
||||
|
||||
The shellctl client is a non-serializable live resource, so Agenton owns
|
||||
only the timing of this scope, not the client itself. Business hooks and
|
||||
tools should call ``_require_client()`` to ensure they are running inside
|
||||
an active resource scope.
|
||||
The actual handle is set in ``on_context_create`` /
|
||||
``on_context_resume``. This scope ensures cleanup if a lifecycle hook
|
||||
fails before the handle is set.
|
||||
"""
|
||||
if self._shellctl_client is not None:
|
||||
raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.")
|
||||
|
||||
client = self.shellctl_client_factory(self.shellctl_entrypoint)
|
||||
self._shellctl_client = client
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._shellctl_client = None
|
||||
await client.close()
|
||||
self._shell_handle = None
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
"""Allocate a new workspace session using the active live shellctl client.
|
||||
"""Provision a new workspace session using the shell provisioner.
|
||||
|
||||
If workspace setup partially succeeds and this hook later raises, the
|
||||
layer never becomes ``ACTIVE``. In that path Agenton still exits
|
||||
``resource_context()``, but ``on_context_delete()`` will not run, so this
|
||||
hook must clean up any tracked shellctl job artifacts before re-raising.
|
||||
The provisioner allocates the workspace directory and returns a
|
||||
``ShellctlHandle``. The layer then bootstraps the workspace with Agent
|
||||
Soul env exports and CLI tool install commands. If workspace setup
|
||||
partially succeeds and this hook later raises, the layer never becomes
|
||||
``ACTIVE``. In that path Agenton still exits ``resource_context()``, but
|
||||
``on_context_delete()`` will not run, so this hook must clean up any
|
||||
tracked artifacts before re-raising.
|
||||
"""
|
||||
try:
|
||||
_ = self._require_client()
|
||||
session_id, workspace_cwd = await self._allocate_workspace()
|
||||
await self._bootstrap_workspace(workspace_cwd)
|
||||
handle = cast(ShellctlHandle, await self.shell_provisioner.provision())
|
||||
self._shell_handle = handle
|
||||
descriptor = handle.descriptor()
|
||||
await self._bootstrap_workspace(descriptor.workspace_cwd)
|
||||
except BaseException:
|
||||
await self._cleanup_create_failure()
|
||||
raise
|
||||
self.runtime_state = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
**self.runtime_state.model_dump(mode="python"),
|
||||
"session_id": session_id,
|
||||
"workspace_cwd": workspace_cwd,
|
||||
"session_id": descriptor.session_id,
|
||||
"workspace_cwd": descriptor.workspace_cwd,
|
||||
}
|
||||
)
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
"""Resume an existing serialized shell session inside an active resource scope.
|
||||
"""Reattach to an existing serialized shell session.
|
||||
|
||||
Builds a ``ShellEnvironmentDescriptor`` from the persisted runtime state
|
||||
and asks the provisioner to reattach without allocating a new workspace.
|
||||
If a future resume path adds self-heal side effects before raising, this
|
||||
hook must compensate for them itself because failed resume attempts never
|
||||
transition the slot back to ``ACTIVE`` and therefore do not receive a
|
||||
normal suspend/delete hook.
|
||||
transition the slot back to ``ACTIVE``.
|
||||
"""
|
||||
_ = self._require_client()
|
||||
_ = self._require_session_identity()
|
||||
session_id, workspace_cwd = self._require_session_identity()
|
||||
descriptor = ShellctlEnvironmentDescriptor(
|
||||
workspace_cwd=workspace_cwd,
|
||||
session_id=session_id,
|
||||
)
|
||||
handle = cast(ShellctlHandle, await self.shell_provisioner.reattach(descriptor))
|
||||
self._shell_handle = handle
|
||||
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
"""Preserve workspace and job state while the live client remains active.
|
||||
"""Close the live client so it does not leak across snapshot boundaries.
|
||||
|
||||
``resource_context()`` owns client teardown after this hook returns.
|
||||
``reattach`` on the next resume creates a fresh client pointing at the
|
||||
same workspace. ``resource_context()`` clears the handle reference after
|
||||
this hook returns.
|
||||
"""
|
||||
_ = self._require_client()
|
||||
handle = self._shell_handle
|
||||
if handle is not None:
|
||||
await handle.client.close()
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
"""Best-effort cleanup for workspace deletion and tracked shellctl jobs.
|
||||
"""Best-effort cleanup for tracked shellctl jobs and workspace deletion.
|
||||
|
||||
Workspace removal must happen before tracked shellctl job deletion because
|
||||
the cleanup itself is implemented as an internal shellctl run. That means
|
||||
deleting job state first would prevent the layer from issuing the
|
||||
proposal-required ``rm -rf`` cleanup job and then cleaning up that final
|
||||
job record along with the rest of the session's tracked shellctl state.
|
||||
``resource_context()`` closes the live client only after this hook
|
||||
finishes.
|
||||
Tracked shellctl jobs are force-deleted on a best-effort basis before the
|
||||
handle is destroyed, since job records may outlive the workspace. The
|
||||
provisioner's ``destroy`` handles workspace removal and client close.
|
||||
"""
|
||||
_ = self._require_client()
|
||||
|
||||
cleanup_job_id: str | None = None
|
||||
identity = self._try_session_identity()
|
||||
if identity is not None:
|
||||
session_id, _workspace_cwd = identity
|
||||
try:
|
||||
cleanup_result = await self._run_internal_job_to_completion(
|
||||
_workspace_cleanup_script(session_id=session_id),
|
||||
cwd=None,
|
||||
)
|
||||
cleanup_job_id = cleanup_result["job_id"]
|
||||
if cleanup_result["exit_code"] != 0:
|
||||
logger.warning(
|
||||
"Shell workspace cleanup job %s for session %s exited with code %s.",
|
||||
cleanup_job_id,
|
||||
session_id,
|
||||
cleanup_result["exit_code"],
|
||||
)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
logger.warning("Failed to remove shell workspace for session %s: %s", session_id, exc)
|
||||
|
||||
tracked_job_ids = _deduplicate_preserving_order(
|
||||
[*self.runtime_state.job_ids, *([cleanup_job_id] if cleanup_job_id is not None else [])]
|
||||
)
|
||||
await self._delete_tracked_jobs_best_effort(tracked_job_ids)
|
||||
handle = self._shell_handle
|
||||
if handle is None:
|
||||
return
|
||||
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
|
||||
self._clear_tracked_jobs()
|
||||
await self.shell_provisioner.destroy(handle)
|
||||
self._shell_handle = None
|
||||
|
||||
async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
|
||||
"""Start a new shell job inside the session workspace."""
|
||||
@ -533,8 +500,10 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
"""Run one trusted server-side script inside the sandbox workspace.
|
||||
|
||||
The sandbox file service uses this boundary for fixed list/read/upload
|
||||
helpers. The layer owns output paging, transient shellctl job cleanup,
|
||||
and optional Agent Stub env injection.
|
||||
helpers. Execution, output draining, and transient shellctl job cleanup
|
||||
are delegated to ``ShellctlExecutor`` from the shell adapter; the layer
|
||||
owns only the optional Agent Stub env injection and the
|
||||
``RemoteCommandResult`` mapping.
|
||||
|
||||
Unlike model-visible ``shell.run``, this server-owned boundary does not
|
||||
inject Agent Soul shell env. Keeping the user-controlled shell env out
|
||||
@ -546,28 +515,32 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
env = self._build_user_shell_run_env()
|
||||
if env is None:
|
||||
raise RuntimeError("Agent Stub environment injection is not available for this shell session.")
|
||||
return await self._run_remote_job_to_completion(
|
||||
script,
|
||||
handle = self._require_handle()
|
||||
executor = ShellctlExecutor(
|
||||
client=handle.client, # pyright: ignore[reportArgumentType]
|
||||
workspace_cwd=self._require_workspace_cwd(),
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
result = await executor.execute(script, env=env)
|
||||
return RemoteCommandResult(
|
||||
status="exited" if not result.truncated() else "running",
|
||||
exit_code=result.exit_code(),
|
||||
output=result.stdout(),
|
||||
truncated=result.truncated(),
|
||||
)
|
||||
|
||||
async def _allocate_workspace(self) -> tuple[str, str]:
|
||||
"""Allocate a unique ``~/workspace/<session_id>`` directory by mkdir collision checks."""
|
||||
for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT):
|
||||
session_id = _generate_session_id()
|
||||
mkdir_result = await self._run_internal_job_to_completion(
|
||||
_workspace_mkdir_script(session_id=session_id),
|
||||
cwd=None,
|
||||
)
|
||||
if mkdir_result["exit_code"] == _WORKSPACE_COLLISION_EXIT_CODE:
|
||||
continue
|
||||
if mkdir_result["exit_code"] != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to create shell workspace {_workspace_cwd(session_id)}: {mkdir_result['status']} exit_code={mkdir_result['exit_code']}"
|
||||
)
|
||||
return session_id, _workspace_cwd(session_id)
|
||||
raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.")
|
||||
def environment_descriptor(self) -> ShellctlEnvironmentDescriptor:
|
||||
"""Return the serializable workspace seed for the shell adapter.
|
||||
|
||||
Bridges this layer's ``runtime_state`` to
|
||||
``dify_agent.adapters.shell``: the returned descriptor identifies the
|
||||
session workspace so an adapter ``ShellProvisionProtocol.reattach`` can
|
||||
rebuild a live handle pointing at it without re-allocating, and without
|
||||
re-entering this layer. Raises ``ValueError`` if the session identity is
|
||||
missing or inconsistent.
|
||||
"""
|
||||
session_id, workspace_cwd = self._require_session_identity()
|
||||
return ShellctlEnvironmentDescriptor(workspace_cwd=workspace_cwd, session_id=session_id)
|
||||
|
||||
async def _bootstrap_workspace(self, workspace_cwd: str) -> None:
|
||||
"""Apply Agent Soul shell config to the freshly-created workspace."""
|
||||
@ -581,21 +554,24 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
)
|
||||
|
||||
async def _cleanup_create_failure(self) -> None:
|
||||
"""Best-effort shellctl job cleanup for create failures before ACTIVE state.
|
||||
"""Best-effort cleanup for create failures before ACTIVE state.
|
||||
|
||||
Agenton only calls ``on_context_delete`` for layers that successfully
|
||||
entered ``ACTIVE``. If ``on_context_create`` fails after issuing one or
|
||||
more internal shellctl jobs, those tracked job artifacts would otherwise
|
||||
leak because no later lifecycle hook owns them. ``resource_context()``
|
||||
still closes the live client for this failed enter attempt after the hook
|
||||
unwinds.
|
||||
entered ``ACTIVE``. If ``on_context_create`` fails after issuing
|
||||
internal jobs, those tracked job artifacts would otherwise leak because
|
||||
no later lifecycle hook owns them. The provisioner's ``destroy`` handles
|
||||
workspace removal and client close.
|
||||
"""
|
||||
if not self.runtime_state.job_ids:
|
||||
handle = self._shell_handle
|
||||
if handle is None:
|
||||
return
|
||||
try:
|
||||
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
|
||||
finally:
|
||||
self._clear_tracked_jobs()
|
||||
if self.runtime_state.job_ids:
|
||||
try:
|
||||
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
|
||||
finally:
|
||||
self._clear_tracked_jobs()
|
||||
await self.shell_provisioner.destroy(handle)
|
||||
self._shell_handle = None
|
||||
|
||||
async def _run_internal_job_to_completion(
|
||||
self,
|
||||
@ -616,56 +592,18 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
self._track_job_result(result)
|
||||
return _job_result_observation(result)
|
||||
|
||||
async def _run_remote_job_to_completion(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
timeout: float,
|
||||
env: dict[str, str] | None,
|
||||
) -> RemoteCommandResult:
|
||||
"""Run a workspace-scoped script to completion and delete its job state.
|
||||
|
||||
Shellctl's ``truncated`` flag is per output window: it means the caller
|
||||
should continue from the returned offset. After this helper drains those
|
||||
windows, only the final window can describe whether output is still
|
||||
unread, usually because the safety window cap was reached.
|
||||
"""
|
||||
client = self._require_client()
|
||||
job_id: str | None = None
|
||||
try:
|
||||
result = await client.run(script, cwd=self._require_workspace_cwd(), env=env, timeout=timeout)
|
||||
job_id = result.job_id
|
||||
self._track_job_result(result)
|
||||
output_parts = [result.output]
|
||||
windows = 1
|
||||
while (result.truncated or not result.done) and windows < _REMOTE_COMMAND_MAX_OUTPUT_WINDOWS:
|
||||
result = await client.wait(result.job_id, offset=self._tracked_offset(result.job_id), timeout=timeout)
|
||||
self._track_job_result(result)
|
||||
output_parts.append(result.output)
|
||||
windows += 1
|
||||
return RemoteCommandResult(
|
||||
job_id=result.job_id,
|
||||
status=result.status.value,
|
||||
done=result.done,
|
||||
exit_code=result.exit_code,
|
||||
output="".join(output_parts),
|
||||
offset=result.offset,
|
||||
truncated=result.truncated,
|
||||
output_path=result.output_path,
|
||||
)
|
||||
finally:
|
||||
if job_id is not None:
|
||||
await self._delete_job_best_effort(job_id)
|
||||
self._forget_tracked_job(job_id)
|
||||
|
||||
def _require_client(self) -> ShellctlClientProtocol:
|
||||
"""Return the live client or reject tool/lifecycle use without one."""
|
||||
if self._shellctl_client is None:
|
||||
def _require_handle(self) -> ShellctlHandle:
|
||||
"""Return the live handle or reject tool/lifecycle use without one."""
|
||||
if self._shell_handle is None:
|
||||
raise RuntimeError(
|
||||
"DifyShellLayer requires an active shellctl client inside resource_context(); "
|
||||
"DifyShellLayer requires an active shell handle inside resource_context(); "
|
||||
+ "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()."
|
||||
)
|
||||
return self._shellctl_client
|
||||
return self._shell_handle
|
||||
|
||||
def _require_client(self) -> ShellctlClientProtocol:
|
||||
"""Return the live shellctl client from the handle."""
|
||||
return cast(ShellctlClientProtocol, self._require_handle().client)
|
||||
|
||||
def _require_workspace_cwd(self) -> str:
|
||||
"""Return the configured workspace directory for user-facing shell jobs."""
|
||||
@ -785,15 +723,6 @@ def _shell_layer_prefix_prompt() -> str:
|
||||
return _SHELL_LAYER_PREFIX_PROMPT
|
||||
|
||||
|
||||
def create_shellctl_client_factory(*, token: str) -> ShellctlClientFactory:
|
||||
"""Return the default shellctl client factory used by server-side providers."""
|
||||
|
||||
def factory(entrypoint: str) -> ShellctlClientProtocol:
|
||||
return ShellctlClient(entrypoint, token=token)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def _job_result_observation(result: JobResult) -> ShellJobObservation:
|
||||
return {
|
||||
"job_id": result.job_id,
|
||||
@ -824,16 +753,8 @@ def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObs
|
||||
return result
|
||||
|
||||
|
||||
def _generate_session_id() -> str:
|
||||
time_component = int(time.time()) & _SESSION_TIME_HEX_MASK
|
||||
random_component = secrets.token_hex(1)
|
||||
if len(random_component) != _SESSION_RANDOM_HEX_LENGTH:
|
||||
raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.")
|
||||
return f"{time_component:05x}{random_component}"
|
||||
|
||||
|
||||
def _workspace_cwd(session_id: str) -> str:
|
||||
return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}"
|
||||
return f"{_WORKSPACE_ROOT}/{session_id}"
|
||||
|
||||
|
||||
def _workspace_bootstrap_script(config: DifyShellLayerConfig) -> str:
|
||||
@ -877,41 +798,11 @@ def _wrap_user_script(script: str, config: DifyShellLayerConfig) -> str:
|
||||
return "\n".join([*lines, script])
|
||||
|
||||
|
||||
def _workspace_mkdir_script(*, session_id: str) -> str:
|
||||
"""Return the internal mkdir command used for proposal-defined collision checks.
|
||||
|
||||
The parent ``$HOME/workspace`` directory is created with ``mkdir -p`` so it
|
||||
can already exist, but the final session directory intentionally uses plain
|
||||
``mkdir``. That second call is the collision detector: when the target
|
||||
already exists, the script maps that case to ``_WORKSPACE_COLLISION_EXIT_CODE``
|
||||
so ``on_context_create()`` can retry with a different random suffix instead
|
||||
of silently reusing another session's workspace.
|
||||
"""
|
||||
safe_session_id = _validated_session_id(session_id)
|
||||
workspace_dir = f"$HOME/workspace/{safe_session_id}"
|
||||
return (
|
||||
'mkdir -p "$HOME/workspace"; '
|
||||
f'if mkdir "{workspace_dir}"; then exit 0; fi; '
|
||||
f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; '
|
||||
"exit 1"
|
||||
)
|
||||
|
||||
|
||||
def _workspace_cleanup_script(*, session_id: str) -> str:
|
||||
return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"'
|
||||
|
||||
|
||||
def _shquote(value: str) -> str:
|
||||
"""Single-quote a value for POSIX shells, escaping embedded single quotes."""
|
||||
return "'" + value.replace("'", "'\\''") + "'"
|
||||
|
||||
|
||||
def _validated_session_id(session_id: str) -> str:
|
||||
if not _SESSION_ID_PATTERN.fullmatch(session_id):
|
||||
raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.")
|
||||
return session_id
|
||||
|
||||
|
||||
def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
@ -928,7 +819,5 @@ __all__ = [
|
||||
"DifyShellLayer",
|
||||
"DifyShellRuntimeState",
|
||||
"RemoteCommandResult",
|
||||
"ShellctlClientFactory",
|
||||
"ShellctlClientProtocol",
|
||||
"create_shellctl_client_factory",
|
||||
]
|
||||
|
||||
@ -44,8 +44,10 @@ from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.knowledge.configs import DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.knowledge.layer import DifyKnowledgeBaseLayer
|
||||
from dify_agent.layers.output.output_layer import DifyOutputLayer
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provisioner
|
||||
from dify_agent.layers.shell.configs import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
|
||||
type DifyAgentLayerProvider = LayerProvider[Any]
|
||||
|
||||
@ -63,13 +65,11 @@ def create_default_layer_providers(
|
||||
) -> tuple[DifyAgentLayerProvider, ...]:
|
||||
"""Return the server provider set of safe config-constructible layers.
|
||||
|
||||
``shellctl_auth_token`` defaults to no token. Passing an explicit empty string
|
||||
to ``create_shellctl_client_factory`` prevents ``ShellctlClient`` from falling
|
||||
back to the Dify Agent process's ``SHELLCTL_AUTH_TOKEN`` environment variable;
|
||||
deployments that enable shellctl bearer auth must set the Dify Agent server
|
||||
setting explicitly.
|
||||
``shellctl_auth_token`` defaults to no token. An explicit empty string
|
||||
prevents ``ShellctlClient`` from falling back to the Dify Agent process's
|
||||
``SHELLCTL_AUTH_TOKEN`` environment variable; deployments that enable
|
||||
shellctl bearer auth must set the Dify Agent server setting explicitly.
|
||||
"""
|
||||
shellctl_token = shellctl_auth_token or ""
|
||||
agent_stub_token_factory: ShellAgentStubTokenFactory | None = None
|
||||
if agent_stub_token_codec is not None:
|
||||
|
||||
@ -102,8 +102,15 @@ def create_default_layer_providers(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint=shellctl_entrypoint,
|
||||
shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token),
|
||||
shell_provisioner=create_shell_provisioner(
|
||||
ShellAdapterSettings(
|
||||
shell_provider="shellctl",
|
||||
shellctl_entrypoint=shellctl_entrypoint,
|
||||
shellctl_auth_token=shellctl_auth_token,
|
||||
)
|
||||
)
|
||||
if shellctl_entrypoint
|
||||
else None,
|
||||
agent_stub_api_base_url=agent_stub_api_base_url,
|
||||
agent_stub_token_factory=agent_stub_token_factory,
|
||||
),
|
||||
|
||||
@ -0,0 +1,331 @@
|
||||
"""Local tests for the shellctl shell adapter and env-driven provider factory.
|
||||
|
||||
These exercise the provider-agnostic boundary contract (provision/execute/wait,
|
||||
file transfer, optional input/interrupt) against a fake shellctl client, plus the
|
||||
``DIFY_AGENT_SHELL_PROVIDER`` selection in the factory. They avoid the private
|
||||
``shell-session-manager`` package by injecting a structural fake client.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.adapters.shell import shellctl
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.factory import create_shell_provisioner
|
||||
from dify_agent.adapters.shell.protocols import (
|
||||
ShellEnvironmentDescriptor,
|
||||
)
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlProvisioner,
|
||||
ShellFileTransferError,
|
||||
ShellProvisionError,
|
||||
)
|
||||
|
||||
_SESSION_HEX = "deadbeefdeadbeef"
|
||||
_WORKSPACE_CWD = f"~/workspace/{_SESSION_HEX}"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Job:
|
||||
job_id: str
|
||||
done: bool = True
|
||||
output: str = ""
|
||||
offset: int = 0
|
||||
truncated: bool = False
|
||||
exit_code: int | None = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _Status:
|
||||
job_id: str
|
||||
done: bool = True
|
||||
offset: int = 0
|
||||
exit_code: int | None = 0
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _RunCall:
|
||||
script: str
|
||||
cwd: str | None
|
||||
env: dict[str, str] | None
|
||||
|
||||
|
||||
type _RunHandler = Callable[[str, str | None, dict[str, str] | None], _Job]
|
||||
type _WaitHandler = Callable[[str, int], _Job]
|
||||
type _InputHandler = Callable[[str, str, int], _Job]
|
||||
type _TerminateHandler = Callable[[str], _Status]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class FakeShellctlClient:
|
||||
"""Structural shellctl client double recording calls and replaying handlers."""
|
||||
|
||||
run_handler: _RunHandler | None = None
|
||||
wait_handler: _WaitHandler | None = None
|
||||
input_handler: _InputHandler | None = None
|
||||
terminate_handler: _TerminateHandler | None = None
|
||||
run_calls: list[_RunCall] = field(default_factory=list)
|
||||
wait_calls: list[tuple[str, int]] = field(default_factory=list)
|
||||
input_calls: list[tuple[str, str, int]] = field(default_factory=list)
|
||||
terminate_calls: list[tuple[str, float]] = field(default_factory=list)
|
||||
delete_calls: list[str] = field(default_factory=list)
|
||||
closed: bool = False
|
||||
|
||||
async def run(self, script, *, cwd=None, env=None, timeout=30.0):
|
||||
del timeout
|
||||
self.run_calls.append(_RunCall(script=script, cwd=cwd, env=env))
|
||||
if self.run_handler is not None:
|
||||
return self.run_handler(script, cwd, env)
|
||||
return _Job(job_id="job", done=True, exit_code=0)
|
||||
|
||||
async def wait(self, job_id, *, offset, timeout=30.0):
|
||||
del timeout
|
||||
self.wait_calls.append((job_id, offset))
|
||||
if self.wait_handler is not None:
|
||||
return self.wait_handler(job_id, offset)
|
||||
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
|
||||
|
||||
async def input(self, job_id, text, *, offset, timeout=30.0):
|
||||
del timeout
|
||||
self.input_calls.append((job_id, text, offset))
|
||||
if self.input_handler is not None:
|
||||
return self.input_handler(job_id, text, offset)
|
||||
return _Job(job_id=job_id, done=True, offset=offset, exit_code=0)
|
||||
|
||||
async def terminate(self, job_id, grace_seconds=10.0):
|
||||
self.terminate_calls.append((job_id, grace_seconds))
|
||||
if self.terminate_handler is not None:
|
||||
return self.terminate_handler(job_id)
|
||||
return _Status(job_id=job_id, done=True, exit_code=130)
|
||||
|
||||
async def delete(self, job_id, *, force=False):
|
||||
del force
|
||||
self.delete_calls.append(job_id)
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fixed_session_id(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: _SESSION_HEX)
|
||||
|
||||
|
||||
def _provisioner(client: FakeShellctlClient) -> ShellctlProvisioner:
|
||||
return ShellctlProvisioner(client_factory=lambda: client)
|
||||
|
||||
|
||||
def test_provision_allocates_workspace_and_execute_drains_merged_output() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
if script.startswith("mkdir"):
|
||||
assert cwd is None
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
return _Job(job_id="user-job", done=False, output="par", offset=3, truncated=False, exit_code=None)
|
||||
|
||||
def wait_handler(job_id: str, offset: int) -> _Job:
|
||||
assert job_id == "user-job"
|
||||
assert offset == 3
|
||||
return _Job(job_id="user-job", done=True, output="tial", offset=7, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
assert handle.workspace_cwd == _WORKSPACE_CWD
|
||||
executor = await handle.get_executor()
|
||||
result = await executor.execute("pwd", env={"FOO": "bar"})
|
||||
assert result.stdout() == "partial"
|
||||
assert result.stderr() == ""
|
||||
assert result.exit_code() == 0
|
||||
assert result.truncated() is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.run_calls[0].cwd is None
|
||||
user_run = next(call for call in client.run_calls if call.script == "pwd")
|
||||
assert user_run.env == {"FOO": "bar"}
|
||||
# completed jobs (internal mkdir + user command) are self-cleaned.
|
||||
assert "mkdir-job" in client.delete_calls
|
||||
assert "user-job" in client.delete_calls
|
||||
|
||||
|
||||
def test_execute_reports_truncated_when_output_window_cap_is_hit() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del cwd, env
|
||||
if script.startswith("mkdir"):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
return _Job(job_id="user-job", done=False, output="x", offset=1, truncated=True, exit_code=None)
|
||||
|
||||
def wait_handler(job_id: str, offset: int) -> _Job:
|
||||
return _Job(job_id=job_id, done=False, output="x", offset=offset + 1, truncated=True, exit_code=None)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
|
||||
async def scenario() -> bool:
|
||||
handle = await _provisioner(client).provision()
|
||||
executor = await handle.get_executor()
|
||||
result = await executor.execute("tail -f log")
|
||||
return result.truncated()
|
||||
|
||||
assert asyncio.run(scenario()) is True
|
||||
# a job that never completed is left intact (not deleted/forgotten).
|
||||
assert "user-job" not in client.delete_calls
|
||||
|
||||
|
||||
def test_provision_failure_closes_client_and_raises() -> None:
|
||||
client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=1)
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(ShellProvisionError):
|
||||
await _provisioner(client).provision()
|
||||
|
||||
asyncio.run(scenario())
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_destroy_runs_cleanup_in_default_cwd_then_closes_client() -> None:
|
||||
client = FakeShellctlClient(run_handler=lambda _script, _cwd, _env: _Job(job_id="job", done=True, exit_code=0))
|
||||
|
||||
async def scenario() -> None:
|
||||
provisioner = _provisioner(client)
|
||||
handle = await provisioner.provision()
|
||||
await provisioner.destroy(handle)
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
cleanup_call = client.run_calls[-1]
|
||||
assert cleanup_call.cwd is None
|
||||
assert _SESSION_HEX in cleanup_call.script and cleanup_call.script.startswith("rm -rf")
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_file_transfer_download_decodes_sentinel_framed_base64() -> None:
|
||||
content = b"hello \x00 world"
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
framed = f"noise{shellctl._TRANSFER_BEGIN}{encoded}{shellctl._TRANSFER_END}trailing"
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
if script.startswith("mkdir"):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
return _Job(job_id="dl-job", done=True, output=framed, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
transfer = await handle.get_file_transfer()
|
||||
downloaded = await transfer.download(remote_path="report.txt")
|
||||
assert downloaded == content
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_file_transfer_download_missing_file_raises() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del cwd, env
|
||||
if script.startswith("mkdir"):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
return _Job(job_id="dl-job", done=True, output="", exit_code=shellctl._DOWNLOAD_MISSING_EXIT_CODE)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
transfer = await handle.get_file_transfer()
|
||||
with pytest.raises(ShellFileTransferError, match="not found"):
|
||||
await transfer.download(remote_path="missing.txt")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_file_transfer_upload_embeds_base64_and_succeeds() -> None:
|
||||
content = b"payload-bytes"
|
||||
encoded = base64.b64encode(content).decode("ascii")
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
if script.startswith('mkdir -p "$HOME'):
|
||||
return _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
assert encoded in script
|
||||
return _Job(job_id="ul-job", done=True, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> None:
|
||||
handle = await _provisioner(client).provision()
|
||||
transfer = await handle.get_file_transfer()
|
||||
await transfer.upload(content=content, remote_path="out.bin")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_provision_exposes_descriptor_seed() -> None:
|
||||
client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env: _Job(job_id="mkdir-job", done=True, exit_code=0)
|
||||
)
|
||||
|
||||
async def scenario() -> ShellEnvironmentDescriptor:
|
||||
handle = await _provisioner(client).provision()
|
||||
return handle.descriptor()
|
||||
|
||||
descriptor = asyncio.run(scenario())
|
||||
assert isinstance(descriptor, ShellctlEnvironmentDescriptor)
|
||||
assert descriptor.workspace_cwd == _WORKSPACE_CWD
|
||||
assert descriptor.session_id == _SESSION_HEX
|
||||
|
||||
|
||||
def test_reattach_rebuilds_handle_without_mkdir_and_executes_in_same_workspace() -> None:
|
||||
descriptor = ShellctlEnvironmentDescriptor(workspace_cwd=_WORKSPACE_CWD, session_id=_SESSION_HEX)
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: dict[str, str] | None) -> _Job:
|
||||
del env
|
||||
assert not script.startswith("mkdir")
|
||||
assert cwd == _WORKSPACE_CWD
|
||||
return _Job(job_id="user-job", done=True, output="ok", offset=2, exit_code=0)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
|
||||
async def scenario() -> str:
|
||||
handle = await _provisioner(client).reattach(descriptor)
|
||||
executor = await handle.get_executor()
|
||||
result = await executor.execute("pwd")
|
||||
return result.stdout()
|
||||
|
||||
assert asyncio.run(scenario()) == "ok"
|
||||
# reattach must not allocate a new workspace.
|
||||
assert all(not call.script.startswith("mkdir") for call in client.run_calls)
|
||||
|
||||
|
||||
def test_factory_unknown_provider_raises() -> None:
|
||||
settings = ShellAdapterSettings(shell_provider="nope")
|
||||
with pytest.raises(ValueError, match="Unknown shell provider"):
|
||||
create_shell_provisioner(settings)
|
||||
|
||||
|
||||
def test_factory_shellctl_requires_entrypoint() -> None:
|
||||
settings = ShellAdapterSettings(shell_provider="shellctl", shellctl_entrypoint=None)
|
||||
with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
|
||||
create_shell_provisioner(settings)
|
||||
|
||||
|
||||
def test_factory_builds_shellctl_provisioner_from_settings() -> None:
|
||||
settings = ShellAdapterSettings(
|
||||
shell_provider="shellctl",
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
)
|
||||
provisioner = create_shell_provisioner(settings)
|
||||
assert isinstance(provisioner, ShellctlProvisioner)
|
||||
@ -2,25 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.layers.drive import DifyDriveLayerConfig, DifyDriveSkillConfig
|
||||
from dify_agent.layers.drive.layer import DifyDriveLayer, DifyDriveLayerError
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult, ShellctlClientFactory
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, RemoteCommandResult
|
||||
|
||||
|
||||
def _unused_client_factory(_entrypoint: str):
|
||||
def _unused_client_factory():
|
||||
raise AssertionError("shellctl client should not be used by these drive-layer tests")
|
||||
|
||||
|
||||
def _shell_layer() -> DifyShellLayer:
|
||||
return DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=cast(ShellctlClientFactory, _unused_client_factory),
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=_unused_client_factory),
|
||||
)
|
||||
|
||||
|
||||
@ -59,14 +57,10 @@ def _remote_result(
|
||||
truncated: bool = False,
|
||||
) -> RemoteCommandResult:
|
||||
return RemoteCommandResult(
|
||||
job_id="remote-drive-pull",
|
||||
status="exited",
|
||||
done=True,
|
||||
exit_code=exit_code,
|
||||
output=output,
|
||||
offset=len(output),
|
||||
truncated=truncated,
|
||||
output_path="/tmp/output.log",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable, Mapping
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import cast
|
||||
|
||||
@ -24,8 +23,14 @@ from dify_agent.layers.shell import (
|
||||
DifyShellSandboxConfig,
|
||||
DifyShellSecretRefConfig,
|
||||
)
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState, ShellctlClientFactory
|
||||
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
|
||||
from dify_agent.adapters.shell.shellctl import (
|
||||
ShellctlEnvironmentDescriptor,
|
||||
ShellctlHandle,
|
||||
ShellctlProvisioner,
|
||||
ShellProvisionError,
|
||||
)
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState
|
||||
from shell_session_manager.shellctl.shared import JobResult, JobStatusName, JobStatusView
|
||||
|
||||
|
||||
def _job_result(
|
||||
@ -116,7 +121,6 @@ class TerminateCall:
|
||||
class DeleteCall:
|
||||
job_id: str
|
||||
force: bool
|
||||
grace_seconds: float | None
|
||||
|
||||
|
||||
class FakeShellctlClient:
|
||||
@ -135,7 +139,7 @@ class FakeShellctlClient:
|
||||
wait_handler: Callable[[str, int, float], JobResult] | None = None,
|
||||
input_handler: Callable[[str, str, int, float], JobResult] | None = None,
|
||||
terminate_handler: Callable[[str, float], JobStatusView] | None = None,
|
||||
delete_handler: Callable[[str, bool, float | None], DeleteJobResponse] | None = None,
|
||||
delete_handler: Callable[[str, bool, float | None], object] | None = None,
|
||||
) -> None:
|
||||
self._run_handler = run_handler
|
||||
self._wait_handler = wait_handler
|
||||
@ -190,26 +194,22 @@ class FakeShellctlClient:
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
self.delete_calls.append(DeleteCall(job_id=job_id, force=force, grace_seconds=grace_seconds))
|
||||
) -> object:
|
||||
self.delete_calls.append(DeleteCall(job_id=job_id, force=force))
|
||||
self.events.append(("delete", job_id))
|
||||
if self._delete_handler is None:
|
||||
return DeleteJobResponse(job_id=job_id)
|
||||
return self._delete_handler(job_id, force, grace_seconds)
|
||||
return None
|
||||
return self._delete_handler(job_id, force, None)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
self.events.append(("close", "client"))
|
||||
|
||||
|
||||
def _shell_layer(
|
||||
*, client_factory: ShellctlClientFactory, config: DifyShellLayerConfig | None = None
|
||||
) -> DifyShellLayer:
|
||||
def _shell_layer(*, client: FakeShellctlClient, config: DifyShellLayerConfig | None = None) -> DifyShellLayer:
|
||||
return DifyShellLayer.from_config_with_settings(
|
||||
config or DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=client_factory,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
)
|
||||
|
||||
|
||||
@ -236,13 +236,12 @@ def _execution_context_layer() -> DifyExecutionContextLayer:
|
||||
)
|
||||
|
||||
|
||||
def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]:
|
||||
def _shell_provider(*, client: FakeShellctlClient) -> LayerProvider[DifyShellLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=client_factory,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
),
|
||||
)
|
||||
|
||||
@ -251,31 +250,34 @@ def test_shell_type_id_constant_matches_implementation_class() -> None:
|
||||
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id
|
||||
|
||||
|
||||
def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_workspace_collision(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
random_suffixes = iter(["aa", "bb"])
|
||||
monkeypatch.setattr(time, "time", lambda: 0x12345F)
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda nbytes: next(random_suffixes))
|
||||
def test_environment_descriptor_returns_workspace_seed_from_runtime_state() -> None:
|
||||
layer = _shell_layer(client=FakeShellctlClient())
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
|
||||
descriptor = layer.environment_descriptor()
|
||||
|
||||
assert descriptor == ShellctlEnvironmentDescriptor(workspace_cwd="~/workspace/abc12ff", session_id="abc12ff")
|
||||
|
||||
|
||||
def test_environment_descriptor_raises_without_session_identity() -> None:
|
||||
layer = _shell_layer(client=FakeShellctlClient())
|
||||
|
||||
with pytest.raises(ValueError, match="session_id or workspace_cwd"):
|
||||
_ = layer.environment_descriptor()
|
||||
|
||||
|
||||
def test_shell_layer_create_provisions_workspace_and_bootstraps(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "deadbeefdeadbeef")
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
assert cwd is None
|
||||
assert env is None
|
||||
assert timeout == 30.0
|
||||
if "2345faa" in script:
|
||||
return _job_result("mkdir-collision", status=JobStatusName.EXITED, done=True, exit_code=17)
|
||||
if "2345fbb" in script:
|
||||
return _job_result("mkdir-success", status=JobStatusName.RUNNING, done=False, offset=4)
|
||||
raise AssertionError(f"Unexpected script: {script}")
|
||||
if cwd is None:
|
||||
assert 'mkdir -p "$HOME/workspace/deadbeefdeadbeef"' in script
|
||||
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
raise AssertionError(f"Unexpected script with cwd={cwd}: {script}")
|
||||
|
||||
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "mkdir-success"
|
||||
assert offset == 4
|
||||
assert timeout == 30.0
|
||||
return _job_result("mkdir-success", status=JobStatusName.EXITED, done=True, exit_code=0, offset=8)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(client=client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
@ -284,29 +286,25 @@ def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_worksp
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert layer.runtime_state.session_id == "2345fbb"
|
||||
assert layer.runtime_state.workspace_cwd == "~/workspace/2345fbb"
|
||||
assert layer.runtime_state.job_ids == ["mkdir-collision", "mkdir-success"]
|
||||
assert layer.runtime_state.job_offsets == {"mkdir-collision": 0, "mkdir-success": 8}
|
||||
assert 'mkdir "$HOME/workspace/2345fbb"' in client.run_calls[1].script
|
||||
assert 'mkdir -p "$HOME/workspace/2345fbb"' not in client.run_calls[1].script
|
||||
assert client.closed is True
|
||||
assert layer.runtime_state.session_id == "deadbeefdeadbeef"
|
||||
assert layer.runtime_state.workspace_cwd == "~/workspace/deadbeefdeadbeef"
|
||||
|
||||
|
||||
def test_shell_layer_suspend_leaves_client_open_until_resource_context_exits() -> None:
|
||||
def test_shell_layer_suspend_closes_client_before_resource_context_exits() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer = _shell_layer(client=client)
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
await layer.on_context_suspend()
|
||||
assert client.closed is False
|
||||
assert client.closed is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None:
|
||||
first_client = FakeShellctlClient(
|
||||
@ -317,15 +315,31 @@ def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None
|
||||
exit_code=0,
|
||||
)
|
||||
)
|
||||
second_client = FakeShellctlClient()
|
||||
created_entrypoints: list[str] = []
|
||||
second_client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(
|
||||
"cleanup-job",
|
||||
status=JobStatusName.EXITED,
|
||||
done=True,
|
||||
exit_code=0,
|
||||
)
|
||||
)
|
||||
clients = iter([first_client, second_client])
|
||||
|
||||
def factory(entrypoint: str) -> FakeShellctlClient:
|
||||
created_entrypoints.append(entrypoint)
|
||||
def factory() -> FakeShellctlClient:
|
||||
return next(clients)
|
||||
|
||||
compositor = Compositor([LayerNode("shell", _shell_provider(client_factory=factory))])
|
||||
provisioner = ShellctlProvisioner(client_factory=factory)
|
||||
|
||||
def make_provider(c: FakeShellctlClient) -> LayerProvider[DifyShellLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shell_provisioner=provisioner,
|
||||
),
|
||||
)
|
||||
|
||||
compositor = Compositor([LayerNode("shell", make_provider(first_client))])
|
||||
|
||||
async def scenario() -> None:
|
||||
async with compositor.enter(configs={"shell": DifyShellLayerConfig()}) as run:
|
||||
@ -353,57 +367,44 @@ def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None
|
||||
assert second_client.closed is False
|
||||
assert resumed_shell.runtime_state.session_id == initial_session_id
|
||||
assert resumed_shell.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}"
|
||||
assert set(resumed_shell.runtime_state.job_ids) == {"mkdir-job", "user-job"}
|
||||
assert resumed_shell.runtime_state.job_offsets == {"mkdir-job": 0, "user-job": 42}
|
||||
assert set(resumed_shell.runtime_state.job_ids) == {"user-job"}
|
||||
assert resumed_shell.runtime_state.job_offsets == {"user-job": 42}
|
||||
resumed_run.suspend_layer_on_exit("shell")
|
||||
|
||||
assert second_client.closed is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert created_entrypoints == ["http://shellctl", "http://shellctl"]
|
||||
|
||||
|
||||
def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_and_closes_client() -> None:
|
||||
def test_shell_layer_delete_force_deletes_tracked_jobs_then_destroys_workspace() -> None:
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
assert script == 'rm -rf -- "$HOME/workspace/abc12ff"'
|
||||
assert cwd is None
|
||||
assert env is None
|
||||
assert timeout == 30.0
|
||||
return _job_result("cleanup-job", status=JobStatusName.RUNNING, done=False, offset=3)
|
||||
del cwd, env, timeout
|
||||
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
|
||||
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "cleanup-job"
|
||||
assert offset == 3
|
||||
assert timeout == 30.0
|
||||
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0, offset=5)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(client=client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer.runtime_state.job_ids = ["user-job", "mkdir-job"]
|
||||
layer.runtime_state.job_offsets = {"user-job": 9, "mkdir-job": 1}
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
await layer.on_context_delete()
|
||||
assert client.closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.events[:2] == [("run", 'rm -rf -- "$HOME/workspace/abc12ff"'), ("wait", "cleanup-job")]
|
||||
assert {call.job_id for call in client.delete_calls} == {"user-job", "mkdir-job", "cleanup-job"}
|
||||
assert all(
|
||||
client.events.index(("delete", call.job_id)) > client.events.index(("wait", "cleanup-job"))
|
||||
for call in client.delete_calls
|
||||
)
|
||||
deleted_job_ids = {call.job_id for call in client.delete_calls}
|
||||
assert {"user-job", "mkdir-job"}.issubset(deleted_job_ids)
|
||||
assert all(call.force is True for call in client.delete_calls)
|
||||
assert layer.runtime_state.job_ids == []
|
||||
assert layer.runtime_state.job_offsets == {}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising() -> None:
|
||||
def test_shell_layer_create_failure_destroys_provisioned_workspace() -> None:
|
||||
client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _env, _timeout: _job_result(
|
||||
"mkdir-failed",
|
||||
@ -412,32 +413,26 @@ def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising
|
||||
exit_code=1,
|
||||
)
|
||||
)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer = _shell_layer(client=client)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(RuntimeError, match="Failed to create shell workspace"):
|
||||
with pytest.raises(ShellProvisionError, match="Failed to create shell workspace"):
|
||||
async with layer.resource_context():
|
||||
await layer.on_context_create()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [call.job_id for call in client.delete_calls] == ["mkdir-failed"]
|
||||
assert all(call.force is True for call in client.delete_calls)
|
||||
assert layer.runtime_state.job_ids == []
|
||||
assert layer.runtime_state.job_offsets == {}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(time, "time", lambda: 0xABC12)
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "ff")
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff")
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
assert env is None
|
||||
if cwd is None:
|
||||
assert timeout == 30.0
|
||||
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
assert cwd == "~/workspace/abc12ff"
|
||||
assert cwd == "~/workspace/abc12ffabc12ff"
|
||||
assert "export PROJECT_NAME='demo project'" in script
|
||||
assert "export QUOTED='it'\\''s ok'" in script
|
||||
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
|
||||
@ -450,7 +445,7 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(
|
||||
client_factory=lambda _entrypoint: client,
|
||||
client=client,
|
||||
config=DifyShellLayerConfig(
|
||||
cli_tools=[
|
||||
DifyShellCliToolConfig(
|
||||
@ -475,17 +470,11 @@ def test_shell_layer_create_bootstraps_agent_soul_shell_config(monkeypatch: pyte
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
|
||||
assert layer.runtime_state.job_ids == ["mkdir-job", "bootstrap-job"]
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"]
|
||||
|
||||
|
||||
def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(time, "time", lambda: 0xABC12)
|
||||
|
||||
def token_hex(_nbytes: int) -> str:
|
||||
return "ff"
|
||||
|
||||
monkeypatch.setattr(secrets, "token_hex", token_hex)
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda _nbytes: "abc12ffabc12ff")
|
||||
|
||||
def run_handler(script: str, cwd: str | None, env: Mapping[str, str] | None, timeout: float) -> JobResult:
|
||||
del timeout
|
||||
@ -493,7 +482,7 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
|
||||
if cwd is None:
|
||||
return _job_result("mkdir-job", status=JobStatusName.EXITED, done=True, exit_code=0)
|
||||
|
||||
assert cwd == "~/workspace/abc12ff"
|
||||
assert cwd == "~/workspace/abc12ffabc12ff"
|
||||
assert "export PROJECT_NAME='demo project'" in script
|
||||
assert 'export OPENAI_API_KEY="${OPENAI_API_KEY:-}"' in script
|
||||
assert "export DIFY_SANDBOX_PROVIDER='independent'" in script
|
||||
@ -503,7 +492,7 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(
|
||||
client_factory=lambda _entrypoint: client,
|
||||
client=client,
|
||||
config=DifyShellLayerConfig(
|
||||
env=[DifyShellEnvVarConfig(name="PROJECT_NAME", value="demo project")],
|
||||
secret_refs=[DifyShellSecretRefConfig(name="OPENAI_API_KEY", ref="secret-1")],
|
||||
@ -526,8 +515,7 @@ def test_shell_layer_injects_agent_soul_env_without_workspace_env_file(monkeypat
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ff"]
|
||||
assert layer.runtime_state.job_ids == ["mkdir-job", "user-job"]
|
||||
assert [call.cwd for call in client.run_calls] == [None, "~/workspace/abc12ffabc12ff"]
|
||||
|
||||
|
||||
def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None:
|
||||
@ -587,12 +575,15 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
|
||||
input_handler=input_handler,
|
||||
terminate_handler=terminate_handler,
|
||||
)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer = _shell_layer(client=client)
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
|
||||
run_tool_def = await tools["shell_run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
wait_tool_def = await tools["shell_wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
@ -642,7 +633,7 @@ def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -
|
||||
|
||||
assert layer.runtime_state.job_ids == ["user-job"]
|
||||
assert layer.runtime_state.job_offsets == {"user-job": 22}
|
||||
assert client.closed is True
|
||||
assert client.closed is False
|
||||
|
||||
|
||||
def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() -> None:
|
||||
@ -657,8 +648,7 @@ def test_shell_layer_injects_agent_stub_env_only_for_user_visible_shell_run() ->
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@ -720,11 +710,14 @@ def test_run_remote_script_uses_workspace_cwd_accumulates_output_and_deletes_job
|
||||
)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer = _shell_layer(client=client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
result = await layer.run_remote_script("printf 'hello world'", timeout=7.5)
|
||||
assert result.output == "hello world"
|
||||
assert result.exit_code == 0
|
||||
@ -753,11 +746,14 @@ def test_run_remote_script_deletes_job_even_when_command_exits_non_zero() -> Non
|
||||
)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer = _shell_layer(client=client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
result = await layer.run_remote_script("exit 17", timeout=3.0)
|
||||
assert result.exit_code == 17
|
||||
assert result.output == "failed\n"
|
||||
@ -785,8 +781,7 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
|
||||
client = FakeShellctlClient(run_handler=run_handler)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(agent_stub_drive_ref="agent-1"),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@ -797,6 +792,9 @@ def test_run_remote_script_can_inject_agent_stub_env_for_server_owned_uploads()
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
_ = await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
|
||||
|
||||
asyncio.run(scenario())
|
||||
@ -815,8 +813,7 @@ def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
|
||||
)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@ -826,6 +823,9 @@ def test_run_remote_script_raises_when_agent_stub_env_is_unavailable() -> None:
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="Agent Stub environment injection is not available"):
|
||||
await layer.run_remote_script("dify-agent file upload report.txt", inject_agent_stub_env=True)
|
||||
|
||||
@ -845,8 +845,7 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
|
||||
)
|
||||
layer = DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
@ -857,6 +856,9 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
_ = await tools["shell_run"].function_schema.call(
|
||||
{"script": "pwd"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
@ -869,12 +871,15 @@ def test_shell_layer_skips_agent_stub_env_without_execution_context_dependency()
|
||||
|
||||
def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer = _shell_layer(client=client)
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer._shell_handle = ShellctlHandle(
|
||||
client=client, workspace_cwd="~/workspace/abc12ff", session_id="abc12ff"
|
||||
)
|
||||
|
||||
wait_result = await tools["shell_wait"].function_schema.call(
|
||||
{"job_id": "missing-job"},
|
||||
@ -902,19 +907,16 @@ def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() ->
|
||||
|
||||
def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer = _shell_layer(client=client)
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(RuntimeError, match="resource_context"):
|
||||
await layer.on_context_suspend()
|
||||
|
||||
run_result = await tools["shell_run"].function_schema.call(
|
||||
{"script": "pwd"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
_assert_error_observation(run_result, includes="resource_context")
|
||||
_assert_error_observation(run_result, includes="shell handle")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
@ -922,7 +924,7 @@ def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_contex
|
||||
|
||||
|
||||
def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None:
|
||||
with pytest.raises(ValueError, match="session_id must match"):
|
||||
with pytest.raises(ValueError, match="session_id must be 7 or 16 lowercase hex characters"):
|
||||
_ = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
"session_id": "../../tmp",
|
||||
|
||||
@ -1,6 +1,10 @@
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
|
||||
import dify_agent.runtime.compositor_factory as compositor_factory_module
|
||||
from dify_agent.adapters.shell.config import ShellAdapterSettings
|
||||
from dify_agent.adapters.shell.protocols import ShellProvisionProtocol
|
||||
from dify_agent.agent_stub.server.tokens.agent_stub import AgentStubTokenCodec
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
@ -8,30 +12,30 @@ from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
|
||||
|
||||
class FakeFactoryClient:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
class FakeProvisioner:
|
||||
"""No-op provisioner for tests that never actually provision a workspace."""
|
||||
|
||||
async def provision(self) -> object:
|
||||
raise AssertionError("provision should not be called by these tests")
|
||||
|
||||
async def reattach(self, descriptor: object) -> object:
|
||||
raise AssertionError("reattach should not be called by these tests")
|
||||
|
||||
async def destroy(self, handle: object) -> None:
|
||||
raise AssertionError("destroy should not be called by these tests")
|
||||
|
||||
|
||||
def test_default_layer_providers_register_shell_layer_with_configured_token_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_tokens: list[str] = []
|
||||
captured_entrypoints: list[str] = []
|
||||
fake_client = FakeFactoryClient()
|
||||
captured_settings: list[ShellAdapterSettings] = []
|
||||
fake_provisioner = FakeProvisioner()
|
||||
|
||||
def fake_create_shellctl_client_factory(*, token: str):
|
||||
captured_tokens.append(token)
|
||||
def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol:
|
||||
captured_settings.append(settings)
|
||||
return cast(ShellProvisionProtocol, fake_provisioner)
|
||||
|
||||
def factory(entrypoint: str) -> FakeFactoryClient:
|
||||
captured_entrypoints.append(entrypoint)
|
||||
return fake_client
|
||||
|
||||
return factory
|
||||
|
||||
monkeypatch.setattr(
|
||||
compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory
|
||||
)
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner)
|
||||
|
||||
providers = create_default_layer_providers(
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
@ -41,34 +45,29 @@ def test_default_layer_providers_register_shell_layer_with_configured_token_fact
|
||||
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
|
||||
assert isinstance(shell_layer, DifyShellLayer)
|
||||
assert shell_layer.shellctl_entrypoint == "http://shellctl.example"
|
||||
assert captured_tokens == ["shell-secret"]
|
||||
assert shell_layer.shellctl_client_factory(shell_layer.shellctl_entrypoint) is fake_client
|
||||
assert captured_entrypoints == ["http://shellctl.example"]
|
||||
assert shell_layer.shell_provisioner is fake_provisioner
|
||||
assert len(captured_settings) == 1
|
||||
assert captured_settings[0].shellctl_entrypoint == "http://shellctl.example"
|
||||
assert captured_settings[0].shellctl_auth_token == "shell-secret"
|
||||
|
||||
|
||||
def test_default_layer_providers_keep_empty_shellctl_token_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_tokens: list[str] = []
|
||||
captured_settings: list[ShellAdapterSettings] = []
|
||||
|
||||
def fake_create_shellctl_client_factory(*, token: str):
|
||||
captured_tokens.append(token)
|
||||
def fake_create_shell_provisioner(settings: ShellAdapterSettings) -> ShellProvisionProtocol:
|
||||
captured_settings.append(settings)
|
||||
return cast(ShellProvisionProtocol, FakeProvisioner())
|
||||
|
||||
def factory(_entrypoint: str) -> FakeFactoryClient:
|
||||
return FakeFactoryClient()
|
||||
|
||||
return factory
|
||||
|
||||
monkeypatch.setattr(
|
||||
compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory
|
||||
)
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shell_provisioner", fake_create_shell_provisioner)
|
||||
|
||||
providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example")
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
_ = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
|
||||
assert captured_tokens == [""]
|
||||
assert len(captured_settings) == 1
|
||||
assert captured_settings[0].shellctl_auth_token is None
|
||||
|
||||
|
||||
def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_is_created() -> None:
|
||||
|
||||
@ -29,6 +29,7 @@ from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer
|
||||
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
@ -1346,8 +1347,7 @@ def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: shell_client,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: shell_client),
|
||||
),
|
||||
)
|
||||
layer_providers = tuple(
|
||||
@ -2342,7 +2342,7 @@ def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None:
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(AgentRunValidationError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
|
||||
with pytest.raises(AgentRunValidationError, match="non-null shell provisioner"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import time
|
||||
from typing import ClassVar
|
||||
@ -8,7 +7,8 @@ from typing import ClassVar
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from shell_session_manager.shellctl.client import ShellctlClient
|
||||
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
|
||||
import dify_agent.server.app as app_module
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
@ -246,12 +246,8 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
assert isinstance(knowledge_layer, DifyKnowledgeBaseLayer)
|
||||
assert knowledge_layer.inner_api_url == "http://dify-api"
|
||||
assert knowledge_layer.inner_api_key == "inner-secret"
|
||||
assert shell_layer.shellctl_entrypoint == "http://shellctl"
|
||||
assert isinstance(shell_layer.shell_provisioner, ShellctlProvisioner)
|
||||
assert shell_layer.agent_stub_api_base_url == "https://agent.example.com/agent-stub"
|
||||
shellctl_client = shell_layer.shellctl_client_factory("http://shellctl")
|
||||
assert isinstance(shellctl_client, ShellctlClient)
|
||||
assert shellctl_client.token == "shell-secret"
|
||||
asyncio.run(shellctl_client.close())
|
||||
http_client = scheduler.plugin_daemon_http_client
|
||||
assert http_client is fake_http_client
|
||||
assert http_client.is_closed is False
|
||||
|
||||
@ -19,6 +19,7 @@ from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.adapters.shell.shellctl import ShellctlProvisioner
|
||||
from dify_agent.protocol import (
|
||||
CreateRunRequest,
|
||||
RunComposition,
|
||||
@ -38,7 +39,7 @@ from dify_agent.server.sandbox_files import (
|
||||
)
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName
|
||||
from shell_session_manager.shellctl.shared import JobResult, JobStatusName
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -71,7 +72,7 @@ class FakeShellctlClient:
|
||||
return self.run_handler(script, cwd, env, timeout)
|
||||
|
||||
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
raise AssertionError(f"Unexpected wait() call for {job_id} offset={offset} timeout={timeout}")
|
||||
return self.run_handler("", None, None, timeout)
|
||||
|
||||
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
raise AssertionError(f"Unexpected input() call for {job_id} text={text!r}")
|
||||
@ -84,11 +85,10 @@ class FakeShellctlClient:
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
del force, grace_seconds
|
||||
) -> object:
|
||||
del force
|
||||
self.delete_calls.append(job_id)
|
||||
return DeleteJobResponse(job_id=job_id)
|
||||
return None
|
||||
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
@ -191,8 +191,7 @@ def _service(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: client,
|
||||
shell_provisioner=ShellctlProvisioner(client_factory=lambda: client),
|
||||
agent_stub_api_base_url="https://agent.example.com/agent-stub",
|
||||
agent_stub_token_factory=lambda execution_context, *, session_id: (
|
||||
f"token-for:{execution_context.tenant_id}:{session_id}"
|
||||
|
||||
@ -17,7 +17,7 @@ const meta = {
|
||||
layout: 'padded',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens, panel surfaces, and scrollbar spacing. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.',
|
||||
component: 'Compound scroll container built on Base UI Scroll Area. The examples mirror the upstream anatomy and focus patterns while applying Dify UI tokens and surface treatments. Base UI ScrollArea.Content defaults to min-width: fit-content, so vertical-only regions that should truncate long content must set min-width: 0 on the content slot.',
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -27,27 +27,7 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const scrollFadeRootClassName = cn(
|
||||
'has-[>_:first-child:focus-visible]:outline-2',
|
||||
'has-[>_:first-child:focus-visible]:outline-offset-0',
|
||||
'has-[>_:first-child:focus-visible]:outline-state-accent-solid',
|
||||
)
|
||||
const rootClassName = 'relative min-h-0 min-w-0'
|
||||
const viewportClassName = 'h-full max-h-full max-w-full rounded-xl border border-divider-subtle bg-components-panel-bg'
|
||||
const fadeViewportClassName = cn(
|
||||
'h-full max-h-full max-w-full rounded-xl bg-components-panel-bg outline-none focus-visible:outline-none',
|
||||
'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
|
||||
)
|
||||
const scrollbarClassName = cn(
|
||||
'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1',
|
||||
'data-[orientation=horizontal]:mx-1 data-[orientation=horizontal]:mb-1',
|
||||
)
|
||||
const verticalContentClassName = 'w-full max-w-full min-w-0'
|
||||
const verticalContentStyle = { minWidth: 0 } satisfies React.CSSProperties
|
||||
const panelClassName = 'min-w-0 rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'
|
||||
const pageClassName = 'min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5'
|
||||
const labelClassName = 'system-xs-medium-uppercase text-text-tertiary'
|
||||
const headingClassName = 'system-md-semibold text-text-primary'
|
||||
|
||||
const appRows = [
|
||||
{ name: 'Invoice Copilot', meta: 'Pinned', icon: 'i-ri-file-list-3-line', selected: true, pinned: true },
|
||||
@ -99,10 +79,10 @@ function StorySection({
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<section className={cn(pageClassName, className)}>
|
||||
<section className={cn('min-w-0 rounded-[28px] border border-divider-subtle bg-background-body p-5', className)}>
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>{eyebrow}</div>
|
||||
<h3 className={headingClassName}>{title}</h3>
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">{eyebrow}</div>
|
||||
<h3 className="system-md-semibold text-text-primary">{title}</h3>
|
||||
<p className="max-w-[72ch] text-pretty system-sm-regular text-text-secondary">{description}</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-center">
|
||||
@ -122,7 +102,7 @@ function VerticalContent({
|
||||
return (
|
||||
<ScrollAreaContent
|
||||
style={verticalContentStyle}
|
||||
className={cn(verticalContentClassName, className)}
|
||||
className={cn('w-full max-w-full min-w-0', className)}
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaContent>
|
||||
@ -136,22 +116,20 @@ export const Anatomy: Story = {
|
||||
title="Base UI compound parts"
|
||||
description="The baseline story mirrors the official Scroll Area anatomy: Root, Viewport, Content, Scrollbar, and Thumb, with keyboard focus drawn by the viewport."
|
||||
>
|
||||
<div className={cn(panelClassName, 'h-75 w-full max-w-105')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Scrollable anatomy example" role="region" className={viewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-4 py-2 pl-3 pr-5 text-text-secondary system-sm-regular leading-6">
|
||||
{articleParagraphs.map(paragraph => (
|
||||
<p key={paragraph}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
<ScrollAreaRoot className="relative h-75 w-full max-w-105 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Scrollable anatomy example" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<VerticalContent className="flex flex-col gap-4 py-2 pl-3 pr-5 text-text-secondary system-sm-regular leading-6">
|
||||
{articleParagraphs.map(paragraph => (
|
||||
<p key={paragraph}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@ -163,28 +141,26 @@ export const Vertical: Story = {
|
||||
title="Long form content"
|
||||
description="Vertical overflow keeps the official viewport focus pattern while constraining content width so text never leaks outside the frame."
|
||||
>
|
||||
<div className={cn(panelClassName, 'h-90 w-full max-w-130')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Long form content" role="region" className={viewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-4 p-4 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
<div className="space-y-1">
|
||||
<div className={labelClassName}>Article</div>
|
||||
<div className={headingClassName}>Scrollable text region</div>
|
||||
</div>
|
||||
{Array.from({ length: 4 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
<ScrollAreaRoot className="relative h-90 w-full max-w-130 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Long form content" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<VerticalContent className="flex flex-col gap-4 p-4 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
<div className="space-y-1">
|
||||
<div className="system-xs-medium-uppercase text-text-tertiary">Article</div>
|
||||
<div className="system-md-semibold text-text-primary">Scrollable text region</div>
|
||||
</div>
|
||||
{Array.from({ length: 4 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@ -196,28 +172,26 @@ export const VerticalTruncation: Story = {
|
||||
title="Constrained content width"
|
||||
description="Use width constraints plus minWidth: 0 on ScrollArea.Content when a vertical-only list should keep vertical scrolling while truncating long labels instead of creating horizontal scroll."
|
||||
>
|
||||
<div className={cn(panelClassName, 'h-48 w-full max-w-80')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Vertical file list" role="region" className={viewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-0.5 p-2">
|
||||
{fileRows.map(file => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-text-line size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate system-sm-regular" title={file}>
|
||||
{file}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
<ScrollAreaRoot className="relative h-48 w-full max-w-80 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Vertical file list" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<VerticalContent className="flex flex-col gap-0.5 p-2">
|
||||
{fileRows.map(file => (
|
||||
<div
|
||||
key={file}
|
||||
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-lg px-2 text-text-secondary hover:bg-state-base-hover"
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-text-line size-4 shrink-0" />
|
||||
<span className="min-w-0 truncate system-sm-regular" title={file}>
|
||||
{file}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@ -229,24 +203,33 @@ export const ScrollFade: Story = {
|
||||
title="Viewport mask with root focus"
|
||||
description="This mirrors the Base UI scroll-fade example: the viewport owns the mask and the root owns the focus outline so the indicator is never clipped."
|
||||
>
|
||||
<div className={cn(panelClassName, 'h-90 w-full max-w-130')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, scrollFadeRootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Scroll fade article" role="region" className={fadeViewportClassName}>
|
||||
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
{Array.from({ length: 5 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
<ScrollAreaRoot className={cn(
|
||||
'relative h-90 w-full max-w-130 min-w-0',
|
||||
'has-[>_:first-child:focus-visible]:outline-2 has-[>_:first-child:focus-visible]:outline-offset-0 has-[>_:first-child:focus-visible]:outline-state-accent-solid',
|
||||
)}
|
||||
>
|
||||
<ScrollAreaViewport
|
||||
aria-label="Scroll fade article"
|
||||
role="region"
|
||||
className={cn(
|
||||
'h-full max-h-full max-w-full rounded-xl bg-components-panel-bg outline-none focus-visible:outline-none',
|
||||
'mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
|
||||
)}
|
||||
>
|
||||
<VerticalContent className="flex flex-col gap-4 px-4 py-3 pr-6 text-text-secondary system-sm-regular leading-6">
|
||||
{Array.from({ length: 5 }, (_, groupIndex) => (
|
||||
articleParagraphs.map(paragraph => (
|
||||
<p key={`${groupIndex}-${paragraph}`}>
|
||||
{paragraph}
|
||||
</p>
|
||||
))
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="opacity-0 data-hovering:opacity-100 data-scrolling:opacity-100 data-scrolling:duration-0">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@ -259,24 +242,22 @@ export const Horizontal: Story = {
|
||||
description="Horizontal overflow keeps Base UI's content sizing behavior and uses the same viewport focus treatment on the scrollable element."
|
||||
className="mx-auto max-w-190"
|
||||
>
|
||||
<div className={cn(panelClassName, 'h-46 w-full max-w-130')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Horizontal numbered row" role="region" className={viewportClassName}>
|
||||
<ScrollAreaContent className="min-h-full min-w-max p-4 pb-6">
|
||||
<div className="grid grid-cols-[repeat(18,6.25rem)] gap-3">
|
||||
{gridCells.slice(0, 18).map(cell => (
|
||||
<div key={cell} className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="horizontal" className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
<ScrollAreaRoot className="relative h-46 w-full max-w-130 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Horizontal numbered row" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<ScrollAreaContent className="min-h-full min-w-max p-4 pb-6">
|
||||
<div className="grid grid-cols-[repeat(18,6.25rem)] gap-3">
|
||||
{gridCells.slice(0, 18).map(cell => (
|
||||
<div key={cell} className="flex h-24 items-center justify-center rounded-xl border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar orientation="horizontal">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@ -288,28 +269,26 @@ export const BothAxes: Story = {
|
||||
title="Numbered grid"
|
||||
description="This follows the official two-axis example: both scrollbars are rendered and Corner reserves the intersection."
|
||||
>
|
||||
<div className={cn(panelClassName, 'h-85 w-full max-w-140')}>
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full p-1')}>
|
||||
<ScrollAreaViewport aria-label="Numbered grid" role="region" className={viewportClassName}>
|
||||
<ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3">
|
||||
<div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3">
|
||||
{gridCells.map(cell => (
|
||||
<div key={cell} className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar orientation="horizontal" className={scrollbarClassName}>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
<ScrollAreaRoot className="relative h-85 w-full max-w-140 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Numbered grid" role="region" className="h-full max-h-full max-w-full rounded-xl border-[0.5px] border-divider-subtle bg-components-panel-bg">
|
||||
<ScrollAreaContent className="pt-3 pr-6 pb-6 pl-3">
|
||||
<div className="grid grid-cols-[repeat(10,6.25rem)] grid-rows-[repeat(10,6.25rem)] gap-3">
|
||||
{gridCells.map(cell => (
|
||||
<div key={cell} className="flex items-center justify-center rounded-lg border border-divider-subtle bg-components-panel-bg-alt tabular-nums system-md-semibold text-text-secondary">
|
||||
{cell}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar orientation="horizontal">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner />
|
||||
</ScrollAreaRoot>
|
||||
</StorySection>
|
||||
),
|
||||
}
|
||||
@ -324,54 +303,50 @@ export const AppSidebar: Story = {
|
||||
title="Main navigation list"
|
||||
description="A Dify-like sidebar keeps business UI outside the primitive while preserving the same Root, Viewport, Content, Scrollbar anatomy."
|
||||
>
|
||||
<div className="w-full max-w-70 rounded-2xl border border-divider-subtle bg-background-body p-3 shadow-lg shadow-shadow-shadow-5">
|
||||
<div className="rounded-xl bg-background-default-subtle p-3">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 rounded-lg bg-state-base-active px-2 text-text-accent">
|
||||
<span className="i-ri-apps-fill size-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate system-sm-semibold">Explore</span>
|
||||
</div>
|
||||
<div className="mb-1.5 flex items-center justify-between px-2">
|
||||
<span className={labelClassName}>Web apps</span>
|
||||
<span className="system-xs-medium text-text-quaternary">{appRows.length}</span>
|
||||
</div>
|
||||
<div className="h-76 min-h-0">
|
||||
<ScrollAreaRoot className={cn(rootClassName, 'h-full')}>
|
||||
<ScrollAreaViewport aria-label="Web apps" role="region" className="h-full max-h-full max-w-full rounded-lg bg-transparent">
|
||||
<VerticalContent className="space-y-0.5">
|
||||
{appRows.map((row, index) => (
|
||||
<div key={row.name} className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 text-left transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-state-accent-solid',
|
||||
row.selected
|
||||
? 'bg-state-base-active text-components-menu-item-text-active'
|
||||
: 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-components-avatar-shape-fill-stop-100">
|
||||
<span aria-hidden className={cn(row.icon, 'size-3.5')} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate system-sm-regular">{row.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 system-2xs-medium-uppercase text-text-quaternary">
|
||||
{row.meta}
|
||||
</span>
|
||||
</button>
|
||||
{index === pinnedCount - 1 && index !== appRows.length - 1 && (
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
<div className="w-full max-w-70 rounded-xl bg-background-default-subtle p-3">
|
||||
<div className="mb-4 flex h-8 items-center gap-2 rounded-lg bg-state-base-active px-2 text-text-accent">
|
||||
<span className="i-ri-apps-fill size-4 shrink-0" aria-hidden />
|
||||
<span className="min-w-0 truncate system-sm-semibold">Explore</span>
|
||||
</div>
|
||||
<div className="mb-1.5 flex items-center justify-between px-2">
|
||||
<span className="system-xs-medium-uppercase text-text-tertiary">Web apps</span>
|
||||
<span className="system-xs-medium text-text-quaternary">{appRows.length}</span>
|
||||
</div>
|
||||
<ScrollAreaRoot className="relative h-76 min-w-0">
|
||||
<ScrollAreaViewport aria-label="Web apps" role="region" className="h-full max-h-full max-w-full rounded-lg bg-transparent">
|
||||
<VerticalContent className="space-y-0.5 pr-3">
|
||||
{appRows.map((row, index) => (
|
||||
<div key={row.name} className="space-y-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 text-left transition-colors outline-none focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-solid focus-visible:outline-state-accent-solid',
|
||||
row.selected
|
||||
? 'bg-state-base-active text-components-menu-item-text-active'
|
||||
: 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid text-components-avatar-shape-fill-stop-100">
|
||||
<span aria-hidden className={cn(row.icon, 'size-3.5')} />
|
||||
</span>
|
||||
<span className="min-w-0 truncate system-sm-regular">{row.name}</span>
|
||||
</span>
|
||||
<span className="shrink-0 rounded-md border border-divider-subtle bg-components-panel-bg-alt px-1.5 py-0.5 system-2xs-medium-uppercase text-text-quaternary">
|
||||
{row.meta}
|
||||
</span>
|
||||
</button>
|
||||
{index === pinnedCount - 1 && index !== appRows.length - 1 && (
|
||||
<div className="my-1 h-px bg-divider-subtle" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</VerticalContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
</div>
|
||||
</StorySection>
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ChatConfig, ChatItemInTree } from '../../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { InputVarType, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { useParams, usePathname } from '@/next/navigation'
|
||||
import { sseGet, ssePost } from '@/service/base'
|
||||
import { useChat } from '../hooks'
|
||||
@ -353,74 +353,6 @@ describe('useChat', () => {
|
||||
expect(result.current.chatList[1]!.id).toBe('m-1')
|
||||
})
|
||||
|
||||
it('should process inputs with the per-send input form override', () => {
|
||||
vi.mocked(ssePost).mockImplementation(async () => undefined)
|
||||
const { result } = renderHook(() => useChat(undefined, {
|
||||
inputs: {},
|
||||
inputsForm: [{
|
||||
type: InputVarType.textInput,
|
||||
label: 'City',
|
||||
variable: 'city',
|
||||
required: true,
|
||||
hide: false,
|
||||
}],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', {
|
||||
query: 'hello',
|
||||
inputs: {
|
||||
enabled: undefined,
|
||||
},
|
||||
overrideInputsForm: [{
|
||||
type: InputVarType.checkbox,
|
||||
label: 'Enabled',
|
||||
variable: 'enabled',
|
||||
required: true,
|
||||
hide: false,
|
||||
}],
|
||||
}, {})
|
||||
})
|
||||
|
||||
expect(ssePost).toHaveBeenCalledWith(
|
||||
'test-url',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
inputs: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should settle a send once when the SSE stream errors', () => {
|
||||
let callbacks: HookCallbacks
|
||||
const onSendSettled = vi.fn()
|
||||
|
||||
vi.mocked(ssePost).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useChat())
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'hello' }, {
|
||||
onSendSettled,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onError()
|
||||
callbacks.onCompleted(true)
|
||||
})
|
||||
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
expect(onSendSettled).toHaveBeenCalledTimes(1)
|
||||
expect(onSendSettled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should handle onThought and different workflow events', async () => {
|
||||
let callbacks: HookCallbacks
|
||||
|
||||
@ -1174,44 +1106,6 @@ describe('useChat', () => {
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should settle a resume once when the event stream errors', () => {
|
||||
let callbacks: HookCallbacks
|
||||
const onSendSettled = vi.fn()
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
callbacks = options as HookCallbacks
|
||||
})
|
||||
|
||||
const prevChatTree = [{
|
||||
id: 'q-1',
|
||||
content: 'query',
|
||||
isAnswer: false,
|
||||
children: [{
|
||||
id: 'm-resume-error',
|
||||
content: 'initial',
|
||||
isAnswer: true,
|
||||
siblingIndex: 0,
|
||||
}],
|
||||
}]
|
||||
|
||||
const { result } = renderHook(() => useChat(undefined, undefined, prevChatTree as ChatItemInTree[]))
|
||||
|
||||
act(() => {
|
||||
result.current.handleResume('m-resume-error', 'wr-error', {
|
||||
isPublicAPI: true,
|
||||
onSendSettled,
|
||||
})
|
||||
})
|
||||
|
||||
act(() => {
|
||||
callbacks.onError()
|
||||
callbacks.onCompleted(true)
|
||||
})
|
||||
|
||||
expect(onSendSettled).toHaveBeenCalledTimes(1)
|
||||
expect(onSendSettled).toHaveBeenCalledWith(true)
|
||||
expect(result.current.isResponding).toBe(false)
|
||||
})
|
||||
|
||||
it('should abort previous workflow event stream when resuming again', () => {
|
||||
const callbacksList: HookCallbacks[] = []
|
||||
vi.mocked(sseGet).mockImplementation(async (_url, _params, options) => {
|
||||
@ -1595,45 +1489,6 @@ describe('useChat', () => {
|
||||
|
||||
expect(clearChatListCallback).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should keep the first send after a reset acknowledgement', () => {
|
||||
let clearChatList = true
|
||||
const clearChatListCallback = vi.fn((nextClearChatList: boolean) => {
|
||||
clearChatList = nextClearChatList
|
||||
})
|
||||
const { rerender, result } = renderHook(() => useChat(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
clearChatList,
|
||||
clearChatListCallback,
|
||||
))
|
||||
|
||||
expect(clearChatListCallback).toHaveBeenCalledWith(false)
|
||||
|
||||
rerender()
|
||||
|
||||
act(() => {
|
||||
result.current.handleSend('test-url', { query: 'first after reset' }, {})
|
||||
})
|
||||
|
||||
expect(ssePost).toHaveBeenCalledWith(
|
||||
'test-url',
|
||||
expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
query: 'first after reset',
|
||||
}),
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
expect(result.current.chatList).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: 'first after reset',
|
||||
isAnswer: false,
|
||||
}),
|
||||
]))
|
||||
})
|
||||
})
|
||||
|
||||
describe('annotations and siblings', () => {
|
||||
|
||||
@ -361,32 +361,6 @@ describe('ChatInputArea', () => {
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should keep the textarea when async send is rejected by the owner', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn().mockResolvedValue(false)
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.type(textarea, 'Keep this message')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled())
|
||||
expect(textarea).toHaveValue('Keep this message')
|
||||
})
|
||||
|
||||
it('should keep the textarea when async send fails', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn().mockRejectedValue(new Error('send failed'))
|
||||
render(<ChatInputArea onSend={onSend} visionConfig={mockVisionConfig} />)
|
||||
const textarea = getTextarea()!
|
||||
|
||||
await user.type(textarea, 'Retry this message')
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.send' }))
|
||||
|
||||
await waitFor(() => expect(onSend).toHaveBeenCalled())
|
||||
expect(textarea).toHaveValue('Retry this message')
|
||||
})
|
||||
|
||||
it('should call onSend and reset the input when pressing Enter', async () => {
|
||||
const user = userEvent.setup({ delay: null })
|
||||
const onSend = vi.fn()
|
||||
|
||||
@ -24,8 +24,6 @@ type AudioRecorderWithPermission = typeof Recorder & {
|
||||
getPermission: () => Promise<void>
|
||||
}
|
||||
|
||||
type SendAcceptance = void | boolean | Promise<void | boolean>
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
readonly?: boolean
|
||||
botName?: string
|
||||
@ -64,19 +62,10 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
const historyRef = useRef([''])
|
||||
const [currentIndex, setCurrentIndex] = useState(-1)
|
||||
const isComposingRef = useRef(false)
|
||||
const queryRef = useRef('')
|
||||
const handleQueryChange = useCallback((value: string) => {
|
||||
queryRef.current = value
|
||||
setQuery(value)
|
||||
setTimeout(handleTextareaResize, 0)
|
||||
}, [handleTextareaResize])
|
||||
const resetAcceptedMessage = useCallback((acceptedQuery: string, acceptedFiles: ReturnType<typeof filesStore.getState>['files']) => {
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (queryRef.current === acceptedQuery)
|
||||
handleQueryChange('')
|
||||
if (files === acceptedFiles)
|
||||
setFiles([])
|
||||
}, [filesStore, handleQueryChange])
|
||||
const handleSend = () => {
|
||||
if (!canSend)
|
||||
return
|
||||
@ -86,23 +75,15 @@ const ChatInputArea = ({ readonly, botName, customPlaceholder, showFeatureBar, s
|
||||
return
|
||||
}
|
||||
if (onSend) {
|
||||
const { files } = filesStore.getState()
|
||||
const { files, setFiles } = filesStore.getState()
|
||||
if (files.some(item => item.transferMethod === TransferMethod.local_file && !item.uploadedId)) {
|
||||
toast.info(t('errorMessage.waitForFileUpload', { ns: 'appDebug' }))
|
||||
return
|
||||
}
|
||||
if (checkInputsForm(inputs, inputsForm)) {
|
||||
const sendResult = onSend(query, files) as SendAcceptance
|
||||
if (sendResult instanceof Promise) {
|
||||
sendResult.then((accepted) => {
|
||||
if (accepted !== false)
|
||||
resetAcceptedMessage(query, files)
|
||||
}).catch(noop)
|
||||
return
|
||||
}
|
||||
|
||||
if (sendResult !== false)
|
||||
resetAcceptedMessage(query, files)
|
||||
onSend(query, files)
|
||||
handleQueryChange('')
|
||||
setFiles([])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -52,7 +52,6 @@ type SendCallback = {
|
||||
onGetConversationMessages?: (conversationId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onGetSuggestedQuestions?: (responseItemId: string, getAbortController: GetAbortController) => Promise<any>
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onSendSettled?: (hasError?: boolean) => void
|
||||
isPublicAPI?: boolean
|
||||
}
|
||||
|
||||
@ -257,19 +256,10 @@ export const useChat = (
|
||||
{
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
onSendSettled,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
let hasSettled = false
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
if (hasSettled)
|
||||
return
|
||||
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
// Re-subscribe to workflow events for the specific message
|
||||
const url = `/workflow/${workflowRunId}/events?include_state_snapshot=true`
|
||||
|
||||
@ -313,28 +303,24 @@ export const useChat = (
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
try {
|
||||
if (hasError)
|
||||
return
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
messageId,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
finally {
|
||||
settleSend(hasError)
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
@ -418,7 +404,6 @@ export const useChat = (
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
},
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
handleResponding(true)
|
||||
@ -684,7 +669,6 @@ export const useChat = (
|
||||
onGetConversationMessages,
|
||||
onGetSuggestedQuestions,
|
||||
onConversationComplete,
|
||||
onSendSettled,
|
||||
isPublicAPI,
|
||||
}: SendCallback,
|
||||
) => {
|
||||
@ -737,14 +721,13 @@ export const useChat = (
|
||||
handleResponding(true)
|
||||
hasStopRespondedRef.current = false
|
||||
|
||||
const { query, files, inputs, overrideInputsForm, ...restData } = data
|
||||
const requestInputsForm = overrideInputsForm ?? formSettings?.inputsForm ?? []
|
||||
const { query, files, inputs, ...restData } = data
|
||||
const bodyParams = {
|
||||
response_mode: 'streaming',
|
||||
conversation_id: conversationIdRef.current,
|
||||
files: getProcessedFiles(files || []),
|
||||
query,
|
||||
inputs: getProcessedInputs(inputs || {}, requestInputsForm),
|
||||
inputs: getProcessedInputs(inputs || {}, formSettings?.inputsForm || []),
|
||||
...restData,
|
||||
}
|
||||
if (bodyParams?.files?.length) {
|
||||
@ -761,14 +744,6 @@ export const useChat = (
|
||||
|
||||
let isAgentMode = false
|
||||
let hasSetResponseId = false
|
||||
let hasSettled = false
|
||||
const settleSend = (hasError?: boolean) => {
|
||||
if (hasSettled)
|
||||
return
|
||||
|
||||
hasSettled = true
|
||||
onSendSettled?.(hasError)
|
||||
}
|
||||
|
||||
const getOrCreatePlayer = createAudioPlayerManager()
|
||||
|
||||
@ -827,66 +802,62 @@ export const useChat = (
|
||||
async onCompleted(hasError?: boolean) {
|
||||
handleResponding(false)
|
||||
|
||||
try {
|
||||
if (hasError)
|
||||
if (hasError)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
|
||||
const { data }: any = await onGetConversationMessages(
|
||||
conversationIdRef.current,
|
||||
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
|
||||
if (!newResponseItem)
|
||||
return
|
||||
|
||||
if (onConversationComplete)
|
||||
onConversationComplete(conversationIdRef.current)
|
||||
|
||||
if (conversationIdRef.current && !hasStopRespondedRef.current && onGetConversationMessages) {
|
||||
const { data }: any = await onGetConversationMessages(
|
||||
conversationIdRef.current,
|
||||
newAbortController => conversationMessagesAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
const newResponseItem = data.find((item: any) => item.id === responseItem.id)
|
||||
if (!newResponseItem)
|
||||
return
|
||||
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message.at(-1).role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: newResponseItem.answer,
|
||||
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
more: {
|
||||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationIdRef.current,
|
||||
input: {
|
||||
inputs: newResponseItem.inputs,
|
||||
query: newResponseItem.query,
|
||||
},
|
||||
})
|
||||
}
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
catch {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message.at(-1).role !== 'assistant'
|
||||
? [
|
||||
{
|
||||
role: 'assistant',
|
||||
text: newResponseItem.answer,
|
||||
files: newResponseItem.message_files?.filter((file: any) => file.belongs_to === 'assistant') || [],
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
more: {
|
||||
time: formatTime(newResponseItem.created_at, 'hh:mm A'),
|
||||
tokens: newResponseItem.answer_tokens + newResponseItem.message_tokens,
|
||||
latency: newResponseItem.provider_response_latency.toFixed(2),
|
||||
tokens_per_second: newResponseItem.provider_response_latency > 0 ? (newResponseItem.answer_tokens / newResponseItem.provider_response_latency).toFixed(2) : undefined,
|
||||
},
|
||||
// for agent log
|
||||
conversationId: conversationIdRef.current,
|
||||
input: {
|
||||
inputs: newResponseItem.inputs,
|
||||
query: newResponseItem.query,
|
||||
},
|
||||
})
|
||||
}
|
||||
finally {
|
||||
settleSend(hasError)
|
||||
if (config?.suggested_questions_after_answer?.enabled && !hasStopRespondedRef.current && onGetSuggestedQuestions) {
|
||||
try {
|
||||
const { data }: any = await onGetSuggestedQuestions(
|
||||
responseItem.id,
|
||||
newAbortController => suggestedQuestionsAbortControllerRef.current = newAbortController,
|
||||
)
|
||||
setSuggestedQuestions(data)
|
||||
}
|
||||
// eslint-disable-next-line unused-imports/no-unused-vars
|
||||
catch (e) {
|
||||
setSuggestedQuestions([])
|
||||
}
|
||||
}
|
||||
},
|
||||
onFile(file) {
|
||||
@ -994,7 +965,6 @@ export const useChat = (
|
||||
},
|
||||
onError() {
|
||||
handleResponding(false)
|
||||
settleSend(true)
|
||||
updateCurrentQAOnTree({
|
||||
placeholderQuestionId,
|
||||
questionItem,
|
||||
|
||||
@ -28,15 +28,6 @@ type PricingProps = {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const pricingScrollAreaClassNames = {
|
||||
root: 'relative h-full w-full overflow-hidden',
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-h-full min-w-[1200px]',
|
||||
verticalScrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:me-1',
|
||||
horizontalScrollbar: 'data-[orientation=horizontal]:mx-2 data-[orientation=horizontal]:mb-0.5',
|
||||
corner: 'bg-saas-background',
|
||||
} as const
|
||||
|
||||
const Pricing: FC<PricingProps> = ({
|
||||
onCancel,
|
||||
}) => {
|
||||
@ -65,9 +56,9 @@ const Pricing: FC<PricingProps> = ({
|
||||
<DialogContent
|
||||
className="inset-0 size-full max-h-none max-w-none translate-0 overflow-hidden rounded-none border-none bg-saas-background p-0 shadow-none"
|
||||
>
|
||||
<ScrollAreaRoot className={pricingScrollAreaClassNames.root}>
|
||||
<ScrollAreaViewport className={pricingScrollAreaClassNames.viewport}>
|
||||
<ScrollAreaContent className={pricingScrollAreaClassNames.content}>
|
||||
<ScrollAreaRoot className="relative h-full w-full overflow-hidden">
|
||||
<ScrollAreaViewport className="overscroll-contain">
|
||||
<ScrollAreaContent className="min-h-full min-w-300">
|
||||
<div className="relative grid min-h-full grid-rows-[1fr_auto_auto_1fr] overflow-hidden">
|
||||
<div className="absolute inset-x-0 -top-12 -z-10">
|
||||
<NoiseTop />
|
||||
@ -92,16 +83,13 @@ const Pricing: FC<PricingProps> = ({
|
||||
</div>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className={pricingScrollAreaClassNames.verticalScrollbar}>
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaScrollbar
|
||||
orientation="horizontal"
|
||||
className={pricingScrollAreaClassNames.horizontalScrollbar}
|
||||
>
|
||||
<ScrollAreaScrollbar orientation="horizontal">
|
||||
<ScrollAreaThumb className="rounded-full" />
|
||||
</ScrollAreaScrollbar>
|
||||
<ScrollAreaCorner className={pricingScrollAreaClassNames.corner} />
|
||||
<ScrollAreaCorner className="bg-saas-background" />
|
||||
</ScrollAreaRoot>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@ -23,12 +23,6 @@ import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/s
|
||||
import Item from './app-nav-item'
|
||||
import NoApps from './no-apps'
|
||||
|
||||
const expandedSidebarScrollAreaClassNames = {
|
||||
content: 'space-y-0.5',
|
||||
scrollbar: 'data-[orientation=vertical]:my-2 data-[orientation=vertical]:-me-3',
|
||||
viewport: 'overscroll-contain',
|
||||
} as const
|
||||
|
||||
const SideBar = () => {
|
||||
const { t } = useTranslation()
|
||||
const pathname = usePathname()
|
||||
@ -116,7 +110,10 @@ const SideBar = () => {
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea
|
||||
className="h-full"
|
||||
slotClassNames={expandedSidebarScrollAreaClassNames}
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'space-y-0.5 pr-3',
|
||||
}}
|
||||
labelledBy={webAppsLabelId}
|
||||
>
|
||||
{installedAppItems}
|
||||
|
||||
@ -93,7 +93,7 @@ export function ModelSelectorScrollBody({
|
||||
>
|
||||
<ScrollAreaContent className="min-w-0 overflow-x-hidden">{children}</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="z-2 data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaScrollbar className="z-2">
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
@ -297,7 +297,6 @@ export default function IntegrationsPage({
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-h-full',
|
||||
scrollbar: 'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1',
|
||||
}}
|
||||
>
|
||||
<IntegrationSectionRenderer
|
||||
|
||||
@ -21,7 +21,6 @@ export function IntegrationSectionLayout({
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-h-full',
|
||||
scrollbar: 'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1',
|
||||
}}
|
||||
>
|
||||
<div className={bodyClassName}>
|
||||
|
||||
@ -245,7 +245,7 @@ const ProviderList = ({
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
@ -226,7 +226,7 @@ const WebAppsSectionContent = () => {
|
||||
className="overflow-x-hidden"
|
||||
role="region"
|
||||
>
|
||||
<ScrollAreaContent className="w-full max-w-full min-w-0! px-2">
|
||||
<ScrollAreaContent className="w-full max-w-full min-w-0! pr-5 pl-2">
|
||||
{isPending && (
|
||||
<WebAppsSkeleton />
|
||||
)}
|
||||
@ -271,7 +271,7 @@ const WebAppsSectionContent = () => {
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
@ -41,7 +41,7 @@ function PluginTaskList({
|
||||
label={t('task.installing', { ns: 'plugin' })}
|
||||
slotClassNames={{
|
||||
viewport: 'max-h-[420px] overscroll-contain',
|
||||
content: 'w-full! max-w-full! min-w-0! overflow-x-hidden!',
|
||||
content: 'w-full! max-w-full! min-w-0! overflow-x-hidden! pr-3',
|
||||
}}
|
||||
>
|
||||
{runningPlugins.length > 0 && (
|
||||
|
||||
@ -164,7 +164,7 @@ const PluginsPanelResults = ({
|
||||
)}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
@ -180,7 +180,7 @@ const TriggerPluginItem: FC<Props> = ({
|
||||
))}
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
@ -1,12 +1,5 @@
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { act, waitFor } from '@testing-library/react'
|
||||
import { getDefaultStore } from 'jotai'
|
||||
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
} from '@/features/agent-v2/agent-composer/store'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { renderWorkflowHook } from '../../../__tests__/workflow-test-env'
|
||||
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
|
||||
@ -289,10 +282,6 @@ describe('useCreateInlineAgentBinding', () => {
|
||||
describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const store = getDefaultStore()
|
||||
store.set(agentComposerOriginalConfigAtom, undefined)
|
||||
store.set(agentComposerOriginalDraftAtom, defaultAgentSoulConfigFormState)
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
it('saves inline agent composer changes through the workflow node composer API', async () => {
|
||||
@ -328,13 +317,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
getDefaultStore().set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Workflow inline prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
@ -350,7 +332,7 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
agent_soul: expect.objectContaining({
|
||||
schema_version: 1,
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Workflow inline prompt',
|
||||
system_prompt: '',
|
||||
}),
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
@ -396,13 +378,6 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
},
|
||||
})
|
||||
|
||||
act(() => {
|
||||
getDefaultStore().set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Manual inline prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
@ -417,97 +392,8 @@ describe('useWorkflowInlineAgentConfigureSync', () => {
|
||||
save_strategy: 'node_job_only',
|
||||
agent_soul: expect.objectContaining({
|
||||
schema_version: 1,
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Manual inline prompt',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}, expect.any(Object))
|
||||
})
|
||||
|
||||
it('does not save manually when the inline agent composer draft is unchanged', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({
|
||||
nodeId: 'node-1',
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
},
|
||||
enabled: true,
|
||||
}), {
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(mockComposerMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['workflow-agent-composer', 'app-1', 'node-1'])).toBeUndefined()
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('saves the effective inline model when the form draft is unchanged', async () => {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { result } = renderWorkflowHook(() => useWorkflowInlineAgentConfigureSync({
|
||||
nodeId: 'node-1',
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
},
|
||||
currentModel: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
enabled: true,
|
||||
}), {
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(mockComposerMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
plugin_id: 'langgenius/openai',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}), expect.any(Object))
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,7 +2,6 @@ import type { AgentSoulConfig, WorkflowAgentComposerResponse } from '@dify/contr
|
||||
import type { DefaultModelResponse } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useStore as useJotaiStore, useSetAtom } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
@ -145,18 +144,9 @@ export function useWorkflowInlineAgentConfigureSync({
|
||||
if (!enabledRef.current)
|
||||
return
|
||||
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange)
|
||||
return
|
||||
|
||||
return saveComposer(configSnapshot)
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
const saveAgentSoulConfig = useCallback(async (agentSoulConfig: AgentSoulConfig) => {
|
||||
debouncedSaveDraft.cancel?.()
|
||||
return saveComposer(agentSoulConfig)
|
||||
}, [debouncedSaveDraft, saveComposer])
|
||||
return saveComposer(getAgentSoulDraft())
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer])
|
||||
|
||||
useEffect(() => {
|
||||
return store.sub(agentComposerDraftAtom, () => {
|
||||
@ -185,7 +175,6 @@ export function useWorkflowInlineAgentConfigureSync({
|
||||
|
||||
return {
|
||||
draftSavedAt,
|
||||
saveAgentSoulConfig,
|
||||
saveDraft,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,11 @@
|
||||
import type { AgentSoulConfig, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { WorkflowInlineAgentConfigureWorkspace } from '../agent-orchestrate-panel-content'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkoutBuildDraft: vi.fn(),
|
||||
deleteBuildDraft: vi.fn(),
|
||||
loadBuildDraft: vi.fn(),
|
||||
applyBuildDraft: vi.fn(),
|
||||
refreshDebugConversation: vi.fn(),
|
||||
saveBuildDraft: vi.fn(),
|
||||
saveAgentSoulConfig: vi.fn(),
|
||||
saveDraft: vi.fn(),
|
||||
}))
|
||||
|
||||
@ -24,50 +18,14 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate', async () => {
|
||||
const { useAtom } = await import('jotai')
|
||||
const { agentComposerDraftAtom } = await import('@/features/agent-v2/agent-composer/store')
|
||||
|
||||
return {
|
||||
AgentOrchestratePanel: (props: {
|
||||
bottomAction?: ReactNode
|
||||
headerAction?: ReactNode
|
||||
isBuildDraftActive?: boolean
|
||||
readOnly?: boolean
|
||||
}) => {
|
||||
const [draft, setDraft] = useAtom(agentComposerDraftAtom)
|
||||
|
||||
return (
|
||||
<div role="region" aria-label="orchestrate-panel">
|
||||
<span>{`readonly:${props.readOnly ? 'yes' : 'no'}`}</span>
|
||||
<span>{`buildDraft:${props.isBuildDraftActive ? 'yes' : 'no'}`}</span>
|
||||
{props.headerAction}
|
||||
<input
|
||||
aria-label="local composer draft"
|
||||
value={draft.prompt}
|
||||
onChange={event => setDraft({
|
||||
...draft,
|
||||
prompt: event.currentTarget.value,
|
||||
})}
|
||||
/>
|
||||
{props.bottomAction}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar', () => ({
|
||||
AgentBuildDraftBar: (props: {
|
||||
changesCount: number
|
||||
disabled?: boolean
|
||||
onApply: () => void
|
||||
onDiscard: () => void
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/orchestrate', () => ({
|
||||
AgentOrchestratePanel: (props: {
|
||||
bottomBar?: ReactNode
|
||||
headerAction?: ReactNode
|
||||
}) => (
|
||||
<div role="region" aria-label="build-draft-bar">
|
||||
<span>{`changes:${props.changesCount}`}</span>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onApply}>apply build draft</button>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onDiscard}>discard build draft</button>
|
||||
<div role="region" aria-label="orchestrate-panel">
|
||||
{props.headerAction}
|
||||
{props.bottomBar}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
@ -76,97 +34,34 @@ vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-bac
|
||||
AgentBuildPanelBackground: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-chat', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
AgentBuildChat: (props: {
|
||||
conversationId?: string | null
|
||||
onConversationComplete?: () => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSendInterrupted?: () => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
}) => {
|
||||
const [messageSent, setMessageSent] = useState(false)
|
||||
const [sentPrompt, setSentPrompt] = useState<string | undefined>()
|
||||
|
||||
return (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<span>{`build:${props.conversationId ?? 'none'}`}</span>
|
||||
<span>{`sent:${messageSent ? 'yes' : 'no'}`}</span>
|
||||
<span>{`prompt:${sentPrompt ?? 'none'}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.().then((agentSoulConfig) => {
|
||||
setSentPrompt(agentSoulConfig?.prompt?.system_prompt)
|
||||
setMessageSent(true)
|
||||
props.onConversationIdChange?.('build-conversation-new')
|
||||
})
|
||||
}}
|
||||
>
|
||||
send build message
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onConversationComplete?.()}>
|
||||
complete build conversation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMessageSent(true)
|
||||
props.onSendInterrupted?.()
|
||||
}}
|
||||
>
|
||||
fail build conversation
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/components/preview/build-chat', () => ({
|
||||
AgentBuildChat: (props: {
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}) => (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.()
|
||||
}}
|
||||
>
|
||||
start build
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/agent-v2/agent-soul-config', () => ({
|
||||
useWorkflowInlineAgentConfigureSync: () => ({
|
||||
draftSavedAt: undefined,
|
||||
saveAgentSoulConfig: mocks.saveAgentSoulConfig,
|
||||
saveDraft: mocks.saveDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/features/agent-v2/agent-detail/configure/build-draft-query', () => ({
|
||||
agentConfigureConsoleQuery: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
buildDraft: {
|
||||
get: {
|
||||
queryOptions: () => ({
|
||||
queryKey: ['build-draft'],
|
||||
queryFn: mocks.loadBuildDraft,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryKey: () => ['agent-detail'],
|
||||
},
|
||||
debugConversation: {
|
||||
refresh: {
|
||||
post: {
|
||||
mutationOptions: (options?: { onSuccess?: (data: { debug_conversation_id: string }) => void }) => ({
|
||||
mutationFn: mocks.refreshDebugConversation,
|
||||
...options,
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
composer: {
|
||||
get: {
|
||||
queryOptions: vi.fn(),
|
||||
@ -176,17 +71,6 @@ vi.mock('@/service/client', () => ({
|
||||
get: {
|
||||
queryOptions: () => ({ queryKey: ['build-draft'] }),
|
||||
},
|
||||
delete: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.deleteBuildDraft }),
|
||||
},
|
||||
put: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.saveBuildDraft }),
|
||||
},
|
||||
apply: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.applyBuildDraft }),
|
||||
},
|
||||
},
|
||||
checkout: {
|
||||
post: {
|
||||
mutationOptions: () => ({ mutationFn: mocks.checkoutBuildDraft }),
|
||||
@ -198,17 +82,8 @@ vi.mock('@/service/client', () => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function createInlineComposerState({
|
||||
snapshotId = 'snapshot-1',
|
||||
systemPrompt = 'Help with workflow tasks.',
|
||||
}: {
|
||||
snapshotId?: string
|
||||
systemPrompt?: string
|
||||
} = {}): WorkflowAgentComposerResponse {
|
||||
function createInlineComposerState(): WorkflowAgentComposerResponse {
|
||||
return {
|
||||
active_config_snapshot: {
|
||||
id: snapshotId,
|
||||
},
|
||||
agent: {
|
||||
id: 'agent-1',
|
||||
icon: 'A',
|
||||
@ -219,14 +94,14 @@ function createInlineComposerState({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: systemPrompt,
|
||||
system_prompt: 'Help with workflow tasks.',
|
||||
},
|
||||
} satisfies AgentSoulConfig,
|
||||
binding: {
|
||||
id: 'binding-1',
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'agent-1',
|
||||
current_snapshot_id: snapshotId,
|
||||
current_snapshot_id: 'snapshot-1',
|
||||
workflow_id: 'workflow-1',
|
||||
node_id: 'node-1',
|
||||
},
|
||||
@ -236,51 +111,25 @@ function createInlineComposerState({
|
||||
describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.loadBuildDraft.mockRejectedValue(new Response(null, { status: 404 }))
|
||||
mocks.checkoutBuildDraft.mockResolvedValue({
|
||||
agent_soul: {},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.deleteBuildDraft.mockResolvedValue({ result: 'success' })
|
||||
mocks.applyBuildDraft.mockResolvedValue({
|
||||
agent_soul: {},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.refreshDebugConversation.mockResolvedValue({
|
||||
debug_conversation_id: 'build-conversation-refreshed',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Help with workflow tasks.',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveDraft.mockResolvedValue(createInlineComposerState())
|
||||
mocks.saveAgentSoulConfig.mockResolvedValue(createInlineComposerState())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderWorkspace(props: {
|
||||
inlineComposerState?: WorkflowAgentComposerResponse
|
||||
onSaveInlineToRoster?: () => void
|
||||
} = {}) {
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
return render(
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WorkflowInlineAgentConfigureWorkspace
|
||||
agentId="agent-1"
|
||||
appId="app-1"
|
||||
inlineComposerState={props.inlineComposerState ?? createInlineComposerState()}
|
||||
inlineComposerState={createInlineComposerState()}
|
||||
nodeId="node-1"
|
||||
onSaveInlineToRoster={props.onSaveInlineToRoster}
|
||||
open
|
||||
@ -290,12 +139,12 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
}
|
||||
|
||||
describe('Working Directory', () => {
|
||||
it('should show save-to-roster in the configure header menu without rendering the old action bar', async () => {
|
||||
it('should show save-to-roster in the configure header menu without rendering the old action bar', () => {
|
||||
renderWorkspace({
|
||||
onSaveInlineToRoster: vi.fn(),
|
||||
})
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'common.operation.more' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'common.operation.more' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.save' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.cancel' })).not.toBeInTheDocument()
|
||||
})
|
||||
@ -303,7 +152,7 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
it('should show the working directory panel when the header action is clicked', async () => {
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', {
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.workingDirectory.open',
|
||||
}))
|
||||
|
||||
@ -317,372 +166,28 @@ describe('WorkflowInlineAgentConfigureWorkspace', () => {
|
||||
})
|
||||
|
||||
describe('Build Chat', () => {
|
||||
it('should save the workflow agent draft and write that snapshot into the build draft before starting build chat', async () => {
|
||||
mocks.saveDraft.mockResolvedValue(createInlineComposerState({
|
||||
snapshotId: 'snapshot-saved',
|
||||
systemPrompt: 'Saved workflow snapshot prompt.',
|
||||
}))
|
||||
it('should save the workflow agent draft and checkout a build draft before starting build chat', async () => {
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'start build' }))
|
||||
|
||||
await waitFor(() => expect(mocks.saveDraft).toHaveBeenCalled())
|
||||
expect(mocks.saveBuildDraft).toHaveBeenCalledWith({
|
||||
expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
variant: 'agent_app',
|
||||
save_strategy: 'save_to_current_version',
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Saved workflow snapshot prompt.',
|
||||
}),
|
||||
}),
|
||||
force: false,
|
||||
},
|
||||
}, expect.any(Object))
|
||||
const saveDraftCallOrder = mocks.saveDraft.mock.invocationCallOrder[0]
|
||||
const saveBuildDraftCallOrder = mocks.saveBuildDraft.mock.invocationCallOrder[0]
|
||||
const checkoutBuildDraftCallOrder = mocks.checkoutBuildDraft.mock.invocationCallOrder[0]
|
||||
expect(saveDraftCallOrder).toBeDefined()
|
||||
expect(saveBuildDraftCallOrder).toBeDefined()
|
||||
if (saveDraftCallOrder === undefined || saveBuildDraftCallOrder === undefined)
|
||||
throw new Error('Expected workflow draft and build draft saves to be called')
|
||||
expect(checkoutBuildDraftCallOrder).toBeDefined()
|
||||
if (saveDraftCallOrder === undefined || checkoutBuildDraftCallOrder === undefined)
|
||||
throw new Error('Expected save draft and checkout mutations to be called')
|
||||
|
||||
expect(saveDraftCallOrder).toBeLessThan(saveBuildDraftCallOrder)
|
||||
expect(mocks.checkoutBuildDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the saved build draft response as the build chat source', async () => {
|
||||
mocks.saveDraft.mockResolvedValue(createInlineComposerState({
|
||||
snapshotId: 'snapshot-saved',
|
||||
systemPrompt: 'Saved workflow snapshot prompt.',
|
||||
}))
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Normalized build draft prompt.',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
})
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('prompt:Normalized build draft prompt.')
|
||||
})
|
||||
|
||||
it('should enter build draft mode without resetting the current inline build chat', async () => {
|
||||
mocks.loadBuildDraft
|
||||
.mockRejectedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
expect(await screen.findByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeDisabled()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes'))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeDisabled()
|
||||
|
||||
vi.useFakeTimers()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(mocks.loadBuildDraft).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should re-enable inline build draft actions when build chat fails', async () => {
|
||||
mocks.loadBuildDraft
|
||||
.mockRejectedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled())
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'fail build conversation' }))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
})).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should not let a previous inline build completion refresh unlock a new build run', async () => {
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
await screen.findByRole('region', { name: 'build-draft-bar' })
|
||||
const initialBuildDraftLoadCount = mocks.loadBuildDraft.mock.calls.length
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled())
|
||||
|
||||
vi.useFakeTimers()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(mocks.loadBuildDraft).toHaveBeenCalledTimes(initialBuildDraftLoadCount)
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should refresh the inline build debug conversation when restarting after a build send', async () => {
|
||||
mocks.loadBuildDraft
|
||||
.mockRejectedValueOnce(new Response(null, { status: 404 }))
|
||||
.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.saveBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'send build message' }))
|
||||
await waitFor(() => expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new'))
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'fail build conversation' }))
|
||||
fireEvent.click(screen.getByRole('button', {
|
||||
name: 'agentV2.agentDetail.configure.preview.restart',
|
||||
}))
|
||||
|
||||
await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object)))
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
|
||||
it('should apply inline build draft through the workflow node composer owner', async () => {
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Applied inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'apply build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.saveAgentSoulConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Applied inline build prompt',
|
||||
}),
|
||||
})))
|
||||
expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(mocks.applyBuildDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep exiting inline build draft when debug conversation refresh fails after applying', async () => {
|
||||
mocks.refreshDebugConversation.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Applied inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'apply build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.saveAgentSoulConfig).toHaveBeenCalled())
|
||||
expect(mocks.deleteBuildDraft).toHaveBeenCalled()
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
|
||||
})
|
||||
|
||||
it('should refresh the inline build debug conversation when discarding the build draft', async () => {
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Discarded inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'discard build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object)))
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
|
||||
it('should keep exiting inline build draft when debug conversation refresh fails after discarding', async () => {
|
||||
mocks.refreshDebugConversation.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
mocks.loadBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: 'Discarded inline build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
renderWorkspace()
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'discard build draft' }))
|
||||
|
||||
await waitFor(() => expect(mocks.deleteBuildDraft).toHaveBeenCalled())
|
||||
await waitFor(() => expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument())
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
|
||||
})
|
||||
|
||||
it('should keep the composer session mounted when the inline snapshot changes', async () => {
|
||||
const { rerender } = renderWorkspace({
|
||||
inlineComposerState: createInlineComposerState({ snapshotId: 'snapshot-1' }),
|
||||
})
|
||||
const localDraftInput = await screen.findByRole('textbox', { name: 'local composer draft' })
|
||||
|
||||
fireEvent.change(localDraftInput, { target: { value: 'draft still mounted' } })
|
||||
|
||||
rerender(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<WorkflowInlineAgentConfigureWorkspace
|
||||
agentId="agent-1"
|
||||
appId="app-1"
|
||||
inlineComposerState={createInlineComposerState({ snapshotId: 'snapshot-2' })}
|
||||
nodeId="node-1"
|
||||
open
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox', { name: 'local composer draft' })).toHaveValue('draft still mounted')
|
||||
expect(saveDraftCallOrder).toBeLessThan(checkoutBuildDraftCallOrder)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigSnapshotSummaryResponse, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentComposerBindingResponse, WorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -8,37 +8,24 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@langgenius/dify-ui/dropdown-menu'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { skipToken, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtom, useAtomValue, useStore as useJotaiStore, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
import { useAtom } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useDefaultModel, useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { agentComposerDraftAtom, rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { AgentOrchestratePanel } from '@/features/agent-v2/agent-detail/configure/components/orchestrate'
|
||||
import { AgentBuildDraftBar } from '@/features/agent-v2/agent-detail/configure/components/orchestrate/build-draft-bar'
|
||||
import { AgentBuildPanelBackground } from '@/features/agent-v2/agent-detail/configure/components/preview/build-background'
|
||||
import { AgentBuildChat } from '@/features/agent-v2/agent-detail/configure/components/preview/build-chat'
|
||||
import { AgentPreviewHeader } from '@/features/agent-v2/agent-detail/configure/components/preview/header'
|
||||
import { AgentConfigureRightPanelChat } from '@/features/agent-v2/agent-detail/configure/components/preview/right-panel-chat'
|
||||
import { useAgentWorkingDirectoryPanel } from '@/features/agent-v2/agent-detail/configure/components/preview/use-working-directory-panel'
|
||||
import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from '@/features/agent-v2/agent-detail/configure/components/workspace'
|
||||
import {
|
||||
agentConfigureBuildDraftActionsDisabledAtom,
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureScopedAtoms,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '@/features/agent-v2/agent-detail/configure/state'
|
||||
import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from '@/features/agent-v2/agent-detail/configure/use-agent-configure-build-draft'
|
||||
import { useAgentPreviewSoulConfig } from '@/features/agent-v2/agent-detail/configure/hooks'
|
||||
import { usePrepareAgentBuildDraftBeforeRun } from '@/features/agent-v2/agent-detail/configure/use-agent-build-draft-run'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useWorkflowInlineAgentConfigureSync } from '../agent-soul-config'
|
||||
|
||||
@ -163,63 +150,19 @@ export function WorkflowInlineAgentConfigureWorkspace(props: WorkflowInlineAgent
|
||||
)
|
||||
}
|
||||
|
||||
const composerSessionKey = `${nodeId}:${agentId}`
|
||||
|
||||
return (
|
||||
<ScopeProvider key={composerSessionKey} atoms={agentConfigureScopedAtoms} name="WorkflowInlineAgentConfigure">
|
||||
<WorkflowInlineAgentConfigureWorkspaceComposerScope
|
||||
{...props}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentId={agentId}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
/>
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkflowInlineAgentConfigureWorkspaceComposerScope({
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
activeConfigSnapshot,
|
||||
...props
|
||||
}: Omit<WorkflowInlineAgentConfigureWorkspaceProps, 'agentId'> & {
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentId: string
|
||||
agentSoulConfig: AgentSoulConfig
|
||||
}) {
|
||||
const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom)
|
||||
const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom)
|
||||
const buildDraft = useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
activeVersionId: activeConfigSnapshot?.id,
|
||||
composerAgentSoulConfig: agentSoulConfig,
|
||||
isViewingVersion: false,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
})
|
||||
const composerSessionKey = `${props.nodeId}:${agentId}`
|
||||
|
||||
if (buildDraft.isPending) {
|
||||
return (
|
||||
<div className="flex h-full min-h-80 items-center justify-center bg-components-panel-bg">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const composerSessionKey = `${nodeId}:${agentId}:${activeConfigSnapshot?.id ?? 'draft'}`
|
||||
|
||||
return (
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
initialDraft={agentSoulConfigToFormState(agentSoulConfig)}
|
||||
initialOriginalConfig={agentSoulConfig}
|
||||
>
|
||||
<WorkflowInlineAgentConfigureWorkspaceContent
|
||||
{...props}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentId={agentId}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
buildDraft={buildDraft}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
)
|
||||
@ -230,7 +173,6 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
agentId,
|
||||
agentSoulConfig,
|
||||
appId,
|
||||
buildDraft,
|
||||
inlineComposerState,
|
||||
nodeId,
|
||||
onClose,
|
||||
@ -241,25 +183,14 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
agentId: string
|
||||
agentSoulConfig: AgentSoulConfig
|
||||
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
|
||||
}) {
|
||||
const { t } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const jotaiStore = useJotaiStore()
|
||||
const { t } = useTranslation()
|
||||
const [clearChatList, setClearChatList] = useState(false)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const workingDirectoryPanel = useAgentWorkingDirectoryPanel()
|
||||
const composerState = inlineComposerState
|
||||
const buildDraftActionsDisabled = useAtomValue(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const clearPreviewChat = useAtomValue(agentConfigureClearPreviewChatAtom)
|
||||
const conversationIds = useAtomValue(agentConfigureConversationIdsAtom)
|
||||
const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom)
|
||||
const resetConversation = useSetAtom(resetAgentConfigureConversationAtom)
|
||||
const setBuildDraftActionsDisabled = useSetAtom(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const setClearPreviewChat = useSetAtom(agentConfigureClearPreviewChatAtom)
|
||||
const setConversationId = useSetAtom(setAgentConfigureConversationIdAtom)
|
||||
const rebaseComposerDraft = useSetAtom(rebaseAgentComposerDraftAtom)
|
||||
const { currentModel, setConfigureModel, textGenerationModelList } = useAgentOrchestrateModelOptions()
|
||||
const [isApplyingInlineBuildDraft, setIsApplyingInlineBuildDraft] = useState(false)
|
||||
const { draftSavedAt, saveAgentSoulConfig, saveDraft } = useWorkflowInlineAgentConfigureSync({
|
||||
const { draftSavedAt, saveDraft } = useWorkflowInlineAgentConfigureSync({
|
||||
nodeId,
|
||||
baseConfig: agentSoulConfig,
|
||||
currentModel,
|
||||
@ -275,166 +206,14 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
|
||||
onSaved?.(binding)
|
||||
},
|
||||
enabled: open && !!agentSoulConfig && !buildDraft.isActive,
|
||||
enabled: open && !!agentSoulConfig,
|
||||
})
|
||||
const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({
|
||||
onSuccess: ({ debug_conversation_id }) => {
|
||||
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
(agentDetail) => {
|
||||
if (!agentDetail)
|
||||
return agentDetail
|
||||
|
||||
return {
|
||||
...agentDetail,
|
||||
debug_conversation_id,
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
}))
|
||||
const {
|
||||
mutateAsync: refreshDebugConversationRequestAsync,
|
||||
isPending: isRefreshingDebugConversation,
|
||||
} = refreshDebugConversationMutation
|
||||
const refreshDebugConversationInput = useCallback(() => ({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
}), [agentId])
|
||||
const refreshDebugConversationAsync = useCallback(() => {
|
||||
return refreshDebugConversationRequestAsync(refreshDebugConversationInput())
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequestAsync])
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
await refreshDebugConversationAsync().catch(() => undefined)
|
||||
setConversationId({ mode: 'build', conversationId: null })
|
||||
setClearPreviewChat(true)
|
||||
}, [refreshDebugConversationAsync, setClearPreviewChat, setConversationId])
|
||||
const rebaseComposerDraftFromSoulConfig = useCallback((agentSoulConfig?: AgentSoulConfig) => {
|
||||
rebaseComposerDraft({
|
||||
draft: agentSoulConfigToFormState(agentSoulConfig),
|
||||
originalConfig: agentSoulConfig,
|
||||
})
|
||||
}, [rebaseComposerDraft])
|
||||
const buildDraftActions = useAgentConfigureBuildDraftActions({
|
||||
const buildDraftRun = usePrepareAgentBuildDraftBeforeRun({
|
||||
agentId,
|
||||
isActive: buildDraft.isActive,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
rebaseComposerDraft: rebaseComposerDraftFromSoulConfig,
|
||||
refetchBuildDraft: buildDraft.refetch,
|
||||
refetchComposer: async () => ({
|
||||
data: {
|
||||
agent_soul: agentSoulConfig,
|
||||
},
|
||||
}),
|
||||
resetBuildChatSession,
|
||||
saveDraft: async () => {
|
||||
await saveDraft()
|
||||
},
|
||||
setSoulSourceOverride: buildDraft.setSoulSourceOverride,
|
||||
isBuildDraftActive: false,
|
||||
saveDraft,
|
||||
})
|
||||
const { cancelBuildDraftRefresh } = buildDraftActions
|
||||
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
},
|
||||
})
|
||||
const { mutateAsync: saveBuildDraft } = useMutation(consoleQuery.agent.byAgentId.buildDraft.put.mutationOptions())
|
||||
const discardBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions())
|
||||
const getInlineAgentSoulDraft = useCallback(() => formStateToAgentSoulConfig({
|
||||
baseConfig: agentSoulConfig,
|
||||
formState: jotaiStore.get(agentComposerDraftAtom),
|
||||
currentModel,
|
||||
}), [agentSoulConfig, currentModel, jotaiStore])
|
||||
const prepareInlineBuildDraftBeforeRun = useCallback(async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
const configSnapshot = getInlineAgentSoulDraft()
|
||||
const savedComposerState = await saveDraft()
|
||||
const preparedAgentSoulConfig = savedComposerState?.agent_soul ?? configSnapshot
|
||||
const buildDraftState = await saveBuildDraft({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {
|
||||
variant: 'agent_app',
|
||||
save_strategy: 'save_to_current_version',
|
||||
agent_soul: preparedAgentSoulConfig,
|
||||
},
|
||||
})
|
||||
|
||||
const savedBuildAgentSoulConfig = buildDraftState.agent_soul ?? preparedAgentSoulConfig
|
||||
queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraftState)
|
||||
rebaseComposerDraftFromSoulConfig(savedBuildAgentSoulConfig)
|
||||
buildDraft.setSoulSourceOverride('build-draft')
|
||||
return savedBuildAgentSoulConfig
|
||||
}, [agentId, buildDraft, buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, getInlineAgentSoulDraft, queryClient, rebaseComposerDraftFromSoulConfig, saveBuildDraft, saveDraft])
|
||||
const applyInlineBuildDraft = async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
setIsApplyingInlineBuildDraft(true)
|
||||
try {
|
||||
if (!buildDraft.agentSoulConfig)
|
||||
return
|
||||
|
||||
const savedComposerState = await saveAgentSoulConfig(buildDraft.agentSoulConfig)
|
||||
await discardBuildDraftMutation.mutateAsync({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
}).catch(() => undefined)
|
||||
await resetBuildChatSession().catch(() => undefined)
|
||||
buildDraft.setSoulSourceOverride('draft')
|
||||
queryClient.removeQueries({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
rebaseComposerDraftFromSoulConfig(savedComposerState?.agent_soul ?? buildDraft.agentSoulConfig)
|
||||
toast.success(t('api.actionSuccess'))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('api.actionFailed'))
|
||||
}
|
||||
finally {
|
||||
setIsApplyingInlineBuildDraft(false)
|
||||
}
|
||||
}
|
||||
const discardInlineBuildDraft = async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
try {
|
||||
await discardBuildDraftMutation.mutateAsync({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
})
|
||||
await resetBuildChatSession().catch(() => undefined)
|
||||
buildDraft.setSoulSourceOverride('draft')
|
||||
queryClient.removeQueries({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
rebaseComposerDraftFromSoulConfig(agentSoulConfig)
|
||||
toast.success(t('api.actionSuccess'))
|
||||
}
|
||||
catch {
|
||||
toast.error(t('api.actionFailed'))
|
||||
}
|
||||
}
|
||||
const hasRestartCurrentChatTarget = !!conversationIds[rightPanelChatMode] || buildDraft.isActive
|
||||
const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget
|
||||
|| buildDraftActionsDisabled
|
||||
|| isApplyingInlineBuildDraft
|
||||
|| discardBuildDraftMutation.isPending
|
||||
|| isRefreshingDebugConversation
|
||||
const restartCurrentChat = () => {
|
||||
if (isRestartCurrentChatDisabled)
|
||||
return
|
||||
|
||||
if (buildDraft.isActive) {
|
||||
void discardInlineBuildDraft()
|
||||
return
|
||||
}
|
||||
|
||||
resetConversation(rightPanelChatMode)
|
||||
}
|
||||
const previewAgentSoulConfig = useAgentPreviewSoulConfig(agentSoulConfig as AgentSoulConfig | undefined)
|
||||
|
||||
return (
|
||||
<AgentConfigureWorkspace
|
||||
@ -445,30 +224,12 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
appId={appId}
|
||||
nodeId={nodeId}
|
||||
activeConfigSnapshot={activeConfigSnapshot}
|
||||
agentSoulConfig={buildDraft.agentSoulConfig}
|
||||
agentSoulConfig={agentSoulConfig}
|
||||
agentName={composerState?.agent?.name}
|
||||
currentModel={currentModel}
|
||||
textGenerationModelList={textGenerationModelList}
|
||||
draftSavedAt={draftSavedAt}
|
||||
readOnly={buildDraft.isActive}
|
||||
isBuildDraftActive={buildDraft.isActive}
|
||||
showPublishBar={false}
|
||||
bottomAction={buildDraft.isActive
|
||||
? (
|
||||
<AgentBuildDraftBar
|
||||
changesCount={buildDraft.changesCount}
|
||||
disabled={buildDraftActionsDisabled}
|
||||
isApplying={isApplyingInlineBuildDraft}
|
||||
isDiscarding={discardBuildDraftMutation.isPending}
|
||||
onApply={() => {
|
||||
void applyInlineBuildDraft()
|
||||
}}
|
||||
onDiscard={() => {
|
||||
void discardInlineBuildDraft()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
headerAction={onSaveInlineToRoster
|
||||
? <WorkflowInlineAgentConfigureMoreAction onSaveInlineToRoster={onSaveInlineToRoster} />
|
||||
: undefined}
|
||||
@ -491,15 +252,17 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
onModeChange={() => undefined}
|
||||
onToggleChatFeatures={() => undefined}
|
||||
onOpenWorkingDirectory={workingDirectoryPanel.openWorkingDirectory}
|
||||
onRefresh={restartCurrentChat}
|
||||
refreshDisabled={isRestartCurrentChatDisabled}
|
||||
onRefresh={() => {
|
||||
setConversationId(null)
|
||||
setClearChatList(true)
|
||||
}}
|
||||
showChatFeaturesAction={false}
|
||||
trailingAction={(
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex size-8 items-center justify-center rounded-lg text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
|
||||
aria-label={t('operation.close')}
|
||||
aria-label={t('operation.close', { ns: 'common' })}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-4" />
|
||||
</button>
|
||||
@ -507,38 +270,19 @@ function WorkflowInlineAgentConfigureWorkspaceContent({
|
||||
/>
|
||||
)}
|
||||
chat={(
|
||||
<AgentConfigureRightPanelChat
|
||||
<AgentBuildChat
|
||||
agentId={agentId}
|
||||
agentIcon={composerState?.agent?.icon}
|
||||
agentIconBackground={composerState?.agent?.icon_background}
|
||||
agentIconType={composerState?.agent?.icon_type as Parameters<typeof AgentConfigureRightPanelChat>[0]['agentIconType']}
|
||||
agentIconType={composerState?.agent?.icon_type as Parameters<typeof AgentBuildChat>[0]['agentIconType']}
|
||||
agentName={composerState?.agent?.name}
|
||||
agentSoulConfig={buildDraft.agentSoulConfig}
|
||||
clearChatList={clearPreviewChat}
|
||||
conversationIds={conversationIds}
|
||||
agentSoulConfig={previewAgentSoulConfig}
|
||||
clearChatList={clearChatList}
|
||||
conversationId={conversationId}
|
||||
draftType="debug_build"
|
||||
mode={rightPanelChatMode}
|
||||
onClearChatListChange={setClearPreviewChat}
|
||||
onConversationComplete={(mode) => {
|
||||
if (mode === 'build')
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setBuildDraftActionsDisabled(false))
|
||||
}}
|
||||
onConversationIdChange={(mode, conversationId) => {
|
||||
setConversationId({ mode, conversationId })
|
||||
}}
|
||||
onSaveDraftBeforeRun={async () => {
|
||||
setBuildDraftActionsDisabled(true)
|
||||
try {
|
||||
return await prepareInlineBuildDraftBeforeRun()
|
||||
}
|
||||
catch (error) {
|
||||
setBuildDraftActionsDisabled(false)
|
||||
throw error
|
||||
}
|
||||
}}
|
||||
onSendInterrupted={() => {
|
||||
setBuildDraftActionsDisabled(false)
|
||||
}}
|
||||
onClearChatListChange={setClearChatList}
|
||||
onConversationIdChange={setConversationId}
|
||||
onSaveDraftBeforeRun={buildDraftRun.prepareBuildDraftBeforeRun}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -1,41 +1,10 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentSoulConfigWithFiles } from '../conversions'
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conversions'
|
||||
import { defaultAgentSoulConfigFormState } from '../form-state'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
agentComposerOriginalDraftAtom,
|
||||
agentComposerPublishedDraftAtom,
|
||||
rebaseAgentComposerDraftAtom,
|
||||
} from '../store'
|
||||
|
||||
describe('agent composer store conversions', () => {
|
||||
it('rebases draft baselines through the composer state action', () => {
|
||||
const store = createStore()
|
||||
const nextDraft = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Build draft prompt',
|
||||
}
|
||||
const originalConfig = {
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
} satisfies AgentSoulConfig
|
||||
|
||||
store.set(rebaseAgentComposerDraftAtom, {
|
||||
draft: nextDraft,
|
||||
originalConfig,
|
||||
})
|
||||
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt')
|
||||
expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe('Build draft prompt')
|
||||
})
|
||||
|
||||
it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => {
|
||||
const baseConfig: AgentSoulConfigWithFiles = {
|
||||
app_features: {
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { ReactNode } from 'react'
|
||||
import type { AgentSoulConfigFormState } from './form-state'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { defaultAgentSoulConfigFormState } from './form-state'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
agentComposerDraftAtom,
|
||||
agentComposerOriginalConfigAtom,
|
||||
@ -12,6 +12,27 @@ import {
|
||||
agentComposerPublishedDraftAtom,
|
||||
} from './store'
|
||||
|
||||
function createAgentComposerStore({
|
||||
initialDraft,
|
||||
initialOriginalConfig,
|
||||
}: {
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
}) {
|
||||
const store = createStore()
|
||||
|
||||
if (initialOriginalConfig)
|
||||
store.set(agentComposerOriginalConfigAtom, initialOriginalConfig)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerDraftAtom, initialDraft)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerOriginalDraftAtom, initialDraft)
|
||||
if (initialDraft)
|
||||
store.set(agentComposerPublishedDraftAtom, initialDraft)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
export function AgentComposerProvider({
|
||||
children,
|
||||
initialDraft,
|
||||
@ -21,19 +42,18 @@ export function AgentComposerProvider({
|
||||
initialDraft?: AgentSoulConfigFormState
|
||||
initialOriginalConfig?: AgentSoulConfig
|
||||
}) {
|
||||
const draft = initialDraft ?? defaultAgentSoulConfigFormState
|
||||
const storeRef = useRef<ReturnType<typeof createAgentComposerStore> | null>(null)
|
||||
if (!storeRef.current) {
|
||||
storeRef.current = createAgentComposerStore({
|
||||
initialDraft,
|
||||
initialOriginalConfig,
|
||||
})
|
||||
}
|
||||
const store = storeRef.current
|
||||
|
||||
return (
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentComposerOriginalConfigAtom, initialOriginalConfig],
|
||||
[agentComposerDraftAtom, draft],
|
||||
[agentComposerOriginalDraftAtom, draft],
|
||||
[agentComposerPublishedDraftAtom, draft],
|
||||
]}
|
||||
name="AgentComposer"
|
||||
>
|
||||
<JotaiProvider store={store}>
|
||||
{children}
|
||||
</ScopeProvider>
|
||||
</JotaiProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -9,19 +9,6 @@ export const agentComposerOriginalDraftAtom = atom<AgentSoulConfigFormState | un
|
||||
export const agentComposerPublishedDraftAtom = atom<AgentSoulConfigFormState | undefined>(defaultAgentSoulConfigFormState)
|
||||
export const agentComposerDraftAtom = atom<AgentSoulConfigFormState>(defaultAgentSoulConfigFormState)
|
||||
|
||||
export const rebaseAgentComposerDraftAtom = atom(null, (_get, set, {
|
||||
draft,
|
||||
originalConfig,
|
||||
}: {
|
||||
draft: AgentSoulConfigFormState
|
||||
originalConfig?: AgentSoulConfig
|
||||
}) => {
|
||||
set(agentComposerOriginalConfigAtom, originalConfig)
|
||||
set(agentComposerDraftAtom, draft)
|
||||
set(agentComposerOriginalDraftAtom, draft)
|
||||
set(agentComposerPublishedDraftAtom, draft)
|
||||
})
|
||||
|
||||
export const isAgentComposerDirtyAtom = atom((get) => {
|
||||
const originalDraft = get(agentComposerOriginalDraftAtom)
|
||||
const draft = get(agentComposerDraftAtom)
|
||||
|
||||
@ -1,55 +0,0 @@
|
||||
# Agent Configure
|
||||
|
||||
Owns the Agent V2 configure runtime used by the Agent App configure page and workflow inline Agent configure surface, including editable composer draft wiring, build chat sessions, version viewing, build draft mode, and preview side panels.
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- agent-composer
|
||||
- agent-detail/configure/state
|
||||
- agent-detail/configure/use-agent-build-draft-run
|
||||
- agent-detail/configure/use-agent-configure-build-draft
|
||||
- agent-detail/configure/use-agent-configure-sync
|
||||
- agent-detail/configure/components/orchestrate
|
||||
- agent-detail/configure/components/preview
|
||||
- agent-detail/configure/components/workspace
|
||||
|
||||
## External Modules
|
||||
|
||||
- app/components/base/chat
|
||||
- app/components/base/action-button
|
||||
- app/components/base/app-icon
|
||||
- app/components/base/features
|
||||
- app/components/base/file-uploader
|
||||
- app/components/base/infotip
|
||||
- app/components/base/loading
|
||||
- app/components/base/prompt-editor
|
||||
- app/components/base/skeleton
|
||||
- app/components/app/configuration/config/agent/agent-tools
|
||||
- app/components/datasets
|
||||
- app/components/header/account-setting/model-provider-page
|
||||
- app/components/plugins
|
||||
- app/components/tools
|
||||
- app/components/workflow/block-icon
|
||||
- app/components/workflow/block-selector
|
||||
- app/components/workflow/hooks/use-serial-async-callback
|
||||
- app/components/workflow/nodes
|
||||
- app/components/workflow/types
|
||||
- config
|
||||
- context/app-context
|
||||
- context/i18n
|
||||
- context/modal-context
|
||||
- contract/router
|
||||
- hooks/use-format-time-from-now
|
||||
- hooks/use-theme
|
||||
- hooks/use-timestamp
|
||||
- models/datasets
|
||||
- models/debug
|
||||
- models/log
|
||||
- service/base
|
||||
- service/use-common
|
||||
- types/app
|
||||
- types/common
|
||||
- types/i18n
|
||||
- types/workflow
|
||||
- utils/format
|
||||
- utils/var
|
||||
@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AgentConfigurePage } from '../page'
|
||||
@ -51,15 +51,6 @@ const mocks = vi.hoisted(() => ({
|
||||
},
|
||||
}))
|
||||
|
||||
function createDeferredPromise<T>() {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve
|
||||
})
|
||||
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
|
||||
@ -91,9 +82,6 @@ vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
get: {
|
||||
key: () => ['agents'],
|
||||
},
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryOptions: () => ({ queryKey: ['agent'] }),
|
||||
@ -211,56 +199,43 @@ vi.mock('../components/orchestrate', async () => {
|
||||
vi.mock('../components/orchestrate/build-draft-bar', () => ({
|
||||
AgentBuildDraftBar: (props: {
|
||||
changesCount: number
|
||||
disabled?: boolean
|
||||
onApply: () => void
|
||||
onDiscard: () => void
|
||||
}) => (
|
||||
<div role="region" aria-label="build-draft-bar">
|
||||
<span>{`changes:${props.changesCount}`}</span>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onApply}>apply build draft</button>
|
||||
<button type="button" disabled={props.disabled} onClick={props.onDiscard}>discard build draft</button>
|
||||
<button type="button" onClick={props.onApply}>apply build draft</button>
|
||||
<button type="button" onClick={props.onDiscard}>discard build draft</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/preview/build-chat', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
AgentBuildChat: (props: {
|
||||
conversationId?: string | null
|
||||
onConversationComplete?: () => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}) => {
|
||||
const [messageSent, setMessageSent] = useState(false)
|
||||
|
||||
return (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<span>{`build:${props.conversationId ?? 'none'}`}</span>
|
||||
<span>{`sent:${messageSent ? 'yes' : 'no'}`}</span>
|
||||
<button type="button" onClick={() => props.onConversationIdChange?.('build-conversation-new')}>
|
||||
save build conversation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.().then(() => {
|
||||
setMessageSent(true)
|
||||
props.onConversationIdChange?.('build-conversation-new')
|
||||
})
|
||||
}}
|
||||
>
|
||||
send build message
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onConversationComplete?.()}>
|
||||
complete build conversation
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('../components/preview/build-chat', () => ({
|
||||
AgentBuildChat: (props: {
|
||||
conversationId?: string | null
|
||||
onConversationComplete?: () => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}) => (
|
||||
<div role="region" aria-label="build-chat">
|
||||
<span>{`build:${props.conversationId ?? 'none'}`}</span>
|
||||
<button type="button" onClick={() => props.onConversationIdChange?.('build-conversation-new')}>
|
||||
save build conversation
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void props.onSaveDraftBeforeRun?.()
|
||||
}}
|
||||
>
|
||||
send build message
|
||||
</button>
|
||||
<button type="button" onClick={() => props.onConversationComplete?.()}>
|
||||
complete build conversation
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../components/preview/preview-chat', () => ({
|
||||
AgentPreviewChat: (props: {
|
||||
@ -277,20 +252,7 @@ vi.mock('../components/preview/preview-chat', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../components/preview/chat-features-panel', () => ({
|
||||
AgentChatFeaturesPanel: (props: {
|
||||
appFeatures?: {
|
||||
opening_statement?: string
|
||||
}
|
||||
disabled?: boolean
|
||||
show: boolean
|
||||
}) => props.show
|
||||
? (
|
||||
<div role="region" aria-label="chat-features-panel">
|
||||
<span>{`chatFeaturesDisabled:${props.disabled ? 'yes' : 'no'}`}</span>
|
||||
<span>{`opening:${props.appFeatures?.opening_statement ?? ''}`}</span>
|
||||
</div>
|
||||
)
|
||||
: null,
|
||||
AgentChatFeaturesPanel: () => null,
|
||||
}))
|
||||
|
||||
vi.mock('../components/preview/header', () => ({
|
||||
@ -298,10 +260,8 @@ vi.mock('../components/preview/header', () => ({
|
||||
mode: 'build' | 'preview'
|
||||
previewEnabled: boolean
|
||||
onModeChange: (mode: 'build' | 'preview') => void
|
||||
onToggleChatFeatures: () => void
|
||||
onOpenWorkingDirectory: () => void
|
||||
onRefresh: () => void
|
||||
refreshDisabled?: boolean
|
||||
}) => (
|
||||
<div>
|
||||
<div>{props.mode}</div>
|
||||
@ -311,13 +271,10 @@ vi.mock('../components/preview/header', () => ({
|
||||
<button type="button" onClick={() => props.onModeChange('build')}>
|
||||
build mode
|
||||
</button>
|
||||
<button type="button" onClick={props.onToggleChatFeatures}>
|
||||
chat features
|
||||
</button>
|
||||
<button type="button" onClick={props.onOpenWorkingDirectory}>
|
||||
open working directory
|
||||
</button>
|
||||
<button type="button" disabled={props.refreshDisabled} onClick={props.onRefresh}>
|
||||
<button type="button" onClick={props.onRefresh}>
|
||||
restart preview
|
||||
</button>
|
||||
</div>
|
||||
@ -438,6 +395,9 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'build-conversation-new',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
@ -478,40 +438,6 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.queryByRole('region', { name: 'preview-chat', hidden: true })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable restart when there is no conversation or build draft to reset', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.agent = {
|
||||
...mocks.queryState.agent,
|
||||
data: {
|
||||
...mocks.queryState.agent.data,
|
||||
debug_conversation_id: '',
|
||||
},
|
||||
}
|
||||
mocks.queryState.composer = {
|
||||
data: {},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
const restartButton = screen.getByRole('button', { name: 'restart preview' })
|
||||
|
||||
expect(restartButton).toBeDisabled()
|
||||
|
||||
await user.click(restartButton)
|
||||
|
||||
expect(mocks.refreshDebugConversation).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stay in normal draft mode when build draft returns 404 even if a debug conversation exists', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
@ -551,43 +477,6 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep build draft query refresh owned by explicit workflow actions', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
let buildDraftQueryOptions: unknown
|
||||
for (const [options] of vi.mocked(useQuery).mock.calls) {
|
||||
if (Array.isArray(options.queryKey) && options.queryKey[0] === 'build-draft') {
|
||||
buildDraftQueryOptions = options
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
expect(buildDraftQueryOptions).toMatchObject({
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('should enter build draft mode when build draft data exists', () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
@ -635,120 +524,7 @@ describe('AgentConfigurePage', () => {
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show chat features from the active build draft source as read-only', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
app_features: {
|
||||
opening_statement: 'draft opening',
|
||||
},
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
app_features: {
|
||||
opening_statement: 'build opening',
|
||||
},
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'chat features' }))
|
||||
|
||||
expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent('chatFeaturesDisabled:yes')
|
||||
expect(screen.getByRole('region', { name: 'chat-features-panel' })).toHaveTextContent('opening:build opening')
|
||||
})
|
||||
|
||||
it('should switch to build draft mode without resetting the sending chat when sending from normal draft mode', async () => {
|
||||
const queryClient = new QueryClient()
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: undefined as unknown,
|
||||
dataUpdatedAt: 0,
|
||||
error: new Response(null, { status: 404 }),
|
||||
isFetching: false,
|
||||
isError: true,
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:no')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:no')
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.checkoutBuildDraft).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
force: false,
|
||||
},
|
||||
}, expect.any(Object))
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
})
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:build-conversation-new')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('readonly:yes')
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('buildDraft:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should keep the build draft bar disabled while a build conversation is responding', async () => {
|
||||
it('should show the build draft bar after a new build conversation refresh completes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn().mockResolvedValue({})
|
||||
@ -795,13 +571,7 @@ describe('AgentConfigurePage', () => {
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('sent:yes')
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
expect(screen.queryByRole('region', { name: 'build-draft-bar' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
|
||||
@ -813,214 +583,6 @@ describe('AgentConfigurePage', () => {
|
||||
|
||||
expect(refetchBuildDraft).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-draft-bar' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should settle build draft actions when the build completion refresh fails', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn().mockRejectedValue(new Error('refresh failed'))
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: refetchBuildDraft,
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(refetchBuildDraft).toHaveBeenCalledTimes(1)
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeEnabled()
|
||||
})
|
||||
|
||||
it('should not let a previous build completion refresh unlock a new build run', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn().mockResolvedValue({})
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: refetchBuildDraft,
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
|
||||
expect(refetchBuildDraft).not.toHaveBeenCalled()
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should ignore a previous build completion refresh that is already in flight', async () => {
|
||||
vi.useFakeTimers()
|
||||
const queryClient = new QueryClient()
|
||||
const refetchBuildDraft = vi.fn()
|
||||
const staleRefresh = createDeferredPromise<unknown>()
|
||||
refetchBuildDraft.mockReturnValueOnce(staleRefresh.promise)
|
||||
mocks.checkoutBuildDraft.mockResolvedValue({
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
})
|
||||
mocks.queryState.composer = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'draft prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: vi.fn(),
|
||||
}
|
||||
mocks.queryState.buildDraft = {
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'build prompt',
|
||||
},
|
||||
},
|
||||
draft: {},
|
||||
variant: 'agent_app',
|
||||
},
|
||||
dataUpdatedAt: 1,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
isPending: false,
|
||||
isSuccess: true,
|
||||
refetch: refetchBuildDraft,
|
||||
}
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AgentConfigurePage agentId="agent-1" />
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
fireEvent.click(screen.getByRole('button', { name: 'complete build conversation' }))
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
})
|
||||
expect(refetchBuildDraft).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send build message' }))
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
await act(async () => {
|
||||
staleRefresh.resolve({
|
||||
data: {
|
||||
agent_soul: {
|
||||
prompt: {
|
||||
system_prompt: 'stale refreshed prompt',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await staleRefresh.promise
|
||||
})
|
||||
|
||||
expect(screen.getByRole('region', { name: 'orchestrate-panel' })).toHaveTextContent('prompt:build prompt')
|
||||
expect(screen.getByRole('button', { name: 'apply build draft' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'discard build draft' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should discard the build draft when restarting build mode with a build draft', async () => {
|
||||
@ -1069,6 +631,9 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
@ -1128,7 +693,6 @@ describe('AgentConfigurePage', () => {
|
||||
it('should apply the build draft and rebase the composer store from the refetched normal draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
const refetchComposer = vi.fn(async () => {
|
||||
mocks.queryState.composer = {
|
||||
...mocks.queryState.composer,
|
||||
@ -1192,16 +756,13 @@ describe('AgentConfigurePage', () => {
|
||||
},
|
||||
expect.any(Object),
|
||||
))
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agent'],
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agents'],
|
||||
})
|
||||
expect(mocks.refreshDebugConversation).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(refetchComposer).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
@ -1281,6 +842,9 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(refetchComposer).toHaveBeenCalled()
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
@ -1336,6 +900,9 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
})
|
||||
@ -1387,6 +954,9 @@ describe('AgentConfigurePage', () => {
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: 'debug-conversation-old',
|
||||
},
|
||||
}, expect.any(Object))
|
||||
expect(screen.getByRole('region', { name: 'build-chat' })).toHaveTextContent('build:none')
|
||||
await waitFor(() => {
|
||||
|
||||
@ -1,85 +0,0 @@
|
||||
import { createStore } from 'jotai'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureSelectVersionAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
rebaseAgentConfigureComposerAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '../state'
|
||||
|
||||
describe('agent configure state graph', () => {
|
||||
it('selects versions through the shared source override entrypoint', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(agentConfigureSelectVersionAtom, 'snapshot-1')
|
||||
|
||||
expect(store.get(agentConfigureSelectedVersionIdAtom)).toBe('snapshot-1')
|
||||
expect(store.get(agentConfigureSoulSourceOverrideAtom)).toBe('view-version')
|
||||
|
||||
store.set(agentConfigureSelectVersionAtom, null)
|
||||
|
||||
expect(store.get(agentConfigureSelectedVersionIdAtom)).toBeNull()
|
||||
expect(store.get(agentConfigureSoulSourceOverrideAtom)).toBeNull()
|
||||
})
|
||||
|
||||
it('derives the actual chat mode from the visible right panel mode', () => {
|
||||
const store = createStore()
|
||||
|
||||
expect(store.get(agentConfigureRightPanelChatModeAtom)).toBe('build')
|
||||
|
||||
store.set(agentConfigureRightPanelModeAtom, 'preview')
|
||||
|
||||
expect(store.get(agentConfigureRightPanelChatModeAtom)).toBe('build')
|
||||
})
|
||||
|
||||
it('updates and resets conversation state through named actions', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(setAgentConfigureConversationIdAtom, {
|
||||
mode: 'build',
|
||||
conversationId: 'build-conversation-1',
|
||||
})
|
||||
store.set(setAgentConfigureConversationIdAtom, {
|
||||
mode: 'preview',
|
||||
conversationId: 'preview-conversation-1',
|
||||
})
|
||||
|
||||
expect(store.get(agentConfigureConversationIdsAtom)).toEqual({
|
||||
build: 'build-conversation-1',
|
||||
preview: 'preview-conversation-1',
|
||||
})
|
||||
|
||||
store.set(resetAgentConfigureConversationAtom, 'build')
|
||||
|
||||
expect(store.get(agentConfigureConversationIdsAtom)).toEqual({
|
||||
build: null,
|
||||
preview: 'preview-conversation-1',
|
||||
})
|
||||
expect(store.get(agentConfigureClearPreviewChatAtom)).toBe(true)
|
||||
})
|
||||
|
||||
it('tracks composer rebase as a workflow command', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(rebaseAgentConfigureComposerAtom)
|
||||
store.set(rebaseAgentConfigureComposerAtom)
|
||||
|
||||
expect(store.get(agentConfigureComposerRebaseRevisionAtom)).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps independent panel state as separate primitives', () => {
|
||||
const store = createStore()
|
||||
|
||||
store.set(agentConfigureShowPreviewVersionsAtom, true)
|
||||
|
||||
expect(store.get(agentConfigureShowPreviewVersionsAtom)).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -9,7 +9,6 @@ import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/stor
|
||||
import { useAgentConfigureSync } from '../use-agent-configure-sync'
|
||||
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
error: vi.fn(),
|
||||
success: vi.fn(),
|
||||
}))
|
||||
|
||||
@ -89,9 +88,6 @@ vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
agent: {
|
||||
get: {
|
||||
key: () => ['agents'],
|
||||
},
|
||||
byAgentId: {
|
||||
get: {
|
||||
queryKey: ({ input }: { input: { params: { agent_id: string } } }) => [
|
||||
@ -172,7 +168,6 @@ describe('useAgentConfigureSync', () => {
|
||||
it('should automatically save configure page changes to draft', async () => {
|
||||
vi.setSystemTime(1710000100000)
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
@ -188,7 +183,7 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
@ -211,52 +206,14 @@ describe('useAgentConfigureSync', () => {
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toEqual({
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Draft only prompt',
|
||||
}),
|
||||
}),
|
||||
})
|
||||
expect(queryClient.getQueryData(['agent-composer', 'agent-1'])).toBeUndefined()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['agent-detail', 'agent-1'],
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBe(1710000105000)
|
||||
})
|
||||
|
||||
it('should cancel pending autosave when the draft returns to the saved baseline', async () => {
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Temporary prompt',
|
||||
})
|
||||
})
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, defaultAgentSoulConfigFormState)
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should include Agent Soul files when autosaving file changes', async () => {
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
@ -364,27 +321,6 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should keep autosave failures silent and leave the local draft dirty', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Unsaved autosave prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Unsaved autosave prompt')
|
||||
expect(toastMock.error).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should save the latest draft immediately when requested', async () => {
|
||||
vi.setSystemTime(1710000200000)
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
@ -418,73 +354,6 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(result.current.draftSavedAt).toBe(1710000200000)
|
||||
})
|
||||
|
||||
it('should reject explicit save requests when the draft cannot be saved', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Run prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await expect(result.current.saveDraft()).rejects.toThrow('Failed to save agent composer draft.')
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
expect(store.get(agentComposerDraftAtom).prompt).toBe('Run prompt')
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
|
||||
it('should not save the draft immediately when the composer draft is unchanged', async () => {
|
||||
const { queryClient, result } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: true,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(result.current.draftSavedAt).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should save the effective model before run when the form draft is unchanged', async () => {
|
||||
const { result } = renderUseAgentConfigureSync({
|
||||
baseConfig: {
|
||||
schema_version: 1,
|
||||
prompt: {
|
||||
system_prompt: '',
|
||||
},
|
||||
},
|
||||
currentModel: {
|
||||
provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
},
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveDraft()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
body: expect.objectContaining({
|
||||
agent_soul: expect.objectContaining({
|
||||
model: expect.objectContaining({
|
||||
model_provider: 'langgenius/openai/openai',
|
||||
model: 'gpt-4o-mini',
|
||||
plugin_id: 'langgenius/openai',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should reject manual save when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
@ -633,31 +502,6 @@ describe('useAgentConfigureSync', () => {
|
||||
expect(publishAgentMutationFn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reject publish and keep the publish mutation untouched when saving the draft fails', async () => {
|
||||
composerPutMutationFn.mockRejectedValueOnce(new Error('save failed'))
|
||||
const { queryClient, result, store } = renderUseAgentConfigureSync()
|
||||
queryClient.setQueryData(['agent-detail', 'agent-1'], {
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Unpublished prompt',
|
||||
})
|
||||
})
|
||||
|
||||
await expect(result.current.publishDraft()).rejects.toThrow('Failed to save agent composer draft.')
|
||||
|
||||
expect(publishAgentMutationFn).not.toHaveBeenCalled()
|
||||
expect(queryClient.getQueryData(['agent-detail', 'agent-1'])).toEqual({
|
||||
active_config_is_published: false,
|
||||
name: 'Agent',
|
||||
})
|
||||
expect(toastMock.error).toHaveBeenCalledWith('common.api.actionFailed')
|
||||
})
|
||||
|
||||
it('should reject publish when knowledge retrieval validation fails', async () => {
|
||||
const { result, store } = renderUseAgentConfigureSync()
|
||||
|
||||
|
||||
@ -1,29 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentAppDetailWithSite, AgentIconType, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentAppDetailWithSite, AgentIconType } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import type { useAgentConfigureData } from '../hooks'
|
||||
import type { AgentConfigureConversationIds, AgentConfigureRightPanelMode } from './preview/right-panel-chat'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
|
||||
import { rebaseAgentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useAgentConfigureModelOptions } from '../hooks'
|
||||
import {
|
||||
agentConfigureBuildDraftActionsDisabledAtom,
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
agentConfigureRightPanelChatModeAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureShowChatFeaturesAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
resetAgentConfigureConversationAtom,
|
||||
setAgentConfigureConversationIdAtom,
|
||||
} from '../state'
|
||||
import { useAgentConfigureBuildDraftActions, useAgentConfigureBuildDraftData } from '../use-agent-configure-build-draft'
|
||||
import { useAgentConfigureSync } from '../use-agent-configure-sync'
|
||||
import { AgentOrchestratePanel } from './orchestrate'
|
||||
@ -37,6 +24,15 @@ import { useAgentWorkingDirectoryPanel } from './preview/use-working-directory-p
|
||||
import { AgentPreviewVersionsPanel } from './preview/versions-panel'
|
||||
import { AgentConfigurePreviewSurface, AgentConfigureWorkspace } from './workspace'
|
||||
|
||||
type DebugConversationRefreshInput = {
|
||||
params: {
|
||||
agent_id: string
|
||||
}
|
||||
body: {
|
||||
debug_conversation_id: string
|
||||
}
|
||||
}
|
||||
|
||||
export function AgentConfigureComposerScope({
|
||||
agentId,
|
||||
composerRebaseRevision,
|
||||
@ -57,8 +53,6 @@ export function AgentConfigureComposerScope({
|
||||
activeVersionId,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const soulSourceOverride = useAtomValue(agentConfigureSoulSourceOverrideAtom)
|
||||
const setSoulSourceOverride = useSetAtom(agentConfigureSoulSourceOverrideAtom)
|
||||
const isViewingVersion = !!selectedVersionId
|
||||
const buildDraft = useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
@ -66,8 +60,6 @@ export function AgentConfigureComposerScope({
|
||||
composerAgentSoulConfig: composerQuery.data?.agent_soul,
|
||||
isViewingVersion,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
})
|
||||
|
||||
if (buildDraft.isPending) {
|
||||
@ -76,7 +68,9 @@ export function AgentConfigureComposerScope({
|
||||
)
|
||||
}
|
||||
|
||||
const composerSessionKey = `${agentId}:${activeVersionId ?? selectedVersionId ?? 'draft'}:${composerRebaseRevision}`
|
||||
const composerSessionKey = buildDraft.isActive
|
||||
? `${agentId}:${buildDraft.activeVersionId ?? 'build-draft'}`
|
||||
: `${agentId}:${buildDraft.activeVersionId ?? 'draft'}:${composerRebaseRevision}`
|
||||
|
||||
return (
|
||||
<AgentConfigurePageComposerSession
|
||||
@ -112,7 +106,16 @@ function AgentConfigurePageComposerSession({
|
||||
agentQuery,
|
||||
} = configureData
|
||||
const queryClient = useQueryClient()
|
||||
const [showChatFeatures, setShowChatFeatures] = useState(false)
|
||||
const [showPreviewVersions, setShowPreviewVersions] = useState(false)
|
||||
const workingDirectoryPanel = useAgentWorkingDirectoryPanel()
|
||||
const [clearPreviewChat, setClearPreviewChat] = useState(false)
|
||||
const [rightPanelMode, setRightPanelMode] = useState<AgentConfigureRightPanelMode>('build')
|
||||
const [hideBuildDraftBarUntilRefresh, setHideBuildDraftBarUntilRefresh] = useState(false)
|
||||
const [conversationIds, setConversationIds] = useState<AgentConfigureConversationIds>({
|
||||
build: agentQuery.data?.debug_conversation_id ?? null,
|
||||
preview: null,
|
||||
})
|
||||
const agentIconType = agentQuery.data?.icon_type as AgentIconType | null | undefined
|
||||
const refreshDebugConversationMutation = useMutation(consoleQuery.agent.byAgentId.debugConversation.refresh.post.mutationOptions({
|
||||
onSuccess: ({ debug_conversation_id }) => {
|
||||
@ -135,48 +138,73 @@ function AgentConfigurePageComposerSession({
|
||||
mutateAsync: refreshDebugConversationRequestAsync,
|
||||
isPending: isRefreshingDebugConversation,
|
||||
} = refreshDebugConversationMutation
|
||||
const refreshDebugConversationInput = useCallback(() => ({
|
||||
const refreshDebugConversationInput = useCallback((conversationId: string): DebugConversationRefreshInput => ({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
body: {
|
||||
debug_conversation_id: conversationId,
|
||||
},
|
||||
}), [agentId])
|
||||
const refreshDebugConversation = useCallback(() => {
|
||||
refreshDebugConversationRequest(refreshDebugConversationInput())
|
||||
const refreshDebugConversation = useCallback((conversationId: string) => {
|
||||
const input = refreshDebugConversationInput(conversationId)
|
||||
|
||||
refreshDebugConversationRequest(
|
||||
input as unknown as Parameters<typeof refreshDebugConversationRequest>[0],
|
||||
)
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequest])
|
||||
const refreshDebugConversationAsync = useCallback(() => {
|
||||
return refreshDebugConversationRequestAsync(refreshDebugConversationInput())
|
||||
const refreshDebugConversationAsync = useCallback((conversationId: string) => {
|
||||
const input = refreshDebugConversationInput(conversationId)
|
||||
|
||||
return refreshDebugConversationRequestAsync(
|
||||
input as unknown as Parameters<typeof refreshDebugConversationRequestAsync>[0],
|
||||
)
|
||||
}, [refreshDebugConversationInput, refreshDebugConversationRequestAsync])
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
try {
|
||||
await refreshDebugConversationAsync(conversationIds.build ?? '')
|
||||
}
|
||||
finally {
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
build: null,
|
||||
}))
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
}, [conversationIds.build, refreshDebugConversationAsync])
|
||||
|
||||
return (
|
||||
<ScopeProvider
|
||||
atoms={[
|
||||
[agentConfigureConversationIdsAtom, {
|
||||
build: agentQuery.data?.debug_conversation_id ?? null,
|
||||
preview: null,
|
||||
}],
|
||||
]}
|
||||
name="AgentConfigureConversation"
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
>
|
||||
<AgentComposerProvider
|
||||
key={composerSessionKey}
|
||||
initialDraft={agentSoulConfigToFormState(buildDraft.agentSoulConfig)}
|
||||
initialOriginalConfig={buildDraft.agentSoulConfig}
|
||||
>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
agentIconType={agentIconType}
|
||||
buildDraft={buildDraft}
|
||||
configureData={configureData}
|
||||
isRefreshingDebugConversation={isRefreshingDebugConversation}
|
||||
isViewingVersion={isViewingVersion}
|
||||
workingDirectoryPanel={workingDirectoryPanel}
|
||||
onComposerRebase={onComposerRebase}
|
||||
onRefreshDebugConversation={refreshDebugConversation}
|
||||
onRefreshDebugConversationAsync={refreshDebugConversationAsync}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
</ScopeProvider>
|
||||
<AgentConfigurePageComposerContent
|
||||
agentId={agentId}
|
||||
agentIconType={agentIconType}
|
||||
buildDraft={buildDraft}
|
||||
clearPreviewChat={clearPreviewChat}
|
||||
configureData={configureData}
|
||||
conversationIds={conversationIds}
|
||||
hideBuildDraftBarUntilRefresh={hideBuildDraftBarUntilRefresh}
|
||||
isRefreshingDebugConversation={isRefreshingDebugConversation}
|
||||
isViewingVersion={isViewingVersion}
|
||||
resetBuildChatSession={resetBuildChatSession}
|
||||
rightPanelMode={rightPanelMode}
|
||||
setClearPreviewChat={setClearPreviewChat}
|
||||
setConversationIds={setConversationIds}
|
||||
setHideBuildDraftBarUntilRefresh={setHideBuildDraftBarUntilRefresh}
|
||||
setRightPanelMode={setRightPanelMode}
|
||||
setShowChatFeatures={setShowChatFeatures}
|
||||
setShowPreviewVersions={setShowPreviewVersions}
|
||||
showChatFeatures={showChatFeatures}
|
||||
showPreviewVersions={showPreviewVersions}
|
||||
workingDirectoryPanel={workingDirectoryPanel}
|
||||
onComposerRebase={onComposerRebase}
|
||||
onRefreshDebugConversation={refreshDebugConversation}
|
||||
onSelectVersion={onSelectVersion}
|
||||
/>
|
||||
</AgentComposerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
@ -184,25 +212,49 @@ function AgentConfigurePageComposerContent({
|
||||
agentId,
|
||||
agentIconType,
|
||||
buildDraft,
|
||||
clearPreviewChat,
|
||||
configureData,
|
||||
conversationIds,
|
||||
hideBuildDraftBarUntilRefresh,
|
||||
isRefreshingDebugConversation,
|
||||
isViewingVersion,
|
||||
resetBuildChatSession,
|
||||
rightPanelMode,
|
||||
setClearPreviewChat,
|
||||
setConversationIds,
|
||||
setHideBuildDraftBarUntilRefresh,
|
||||
setRightPanelMode,
|
||||
setShowChatFeatures,
|
||||
setShowPreviewVersions,
|
||||
showChatFeatures,
|
||||
showPreviewVersions,
|
||||
workingDirectoryPanel,
|
||||
onComposerRebase,
|
||||
onRefreshDebugConversation,
|
||||
onRefreshDebugConversationAsync,
|
||||
onSelectVersion,
|
||||
}: {
|
||||
agentId: string
|
||||
agentIconType: AgentIconType | null | undefined
|
||||
buildDraft: ReturnType<typeof useAgentConfigureBuildDraftData>
|
||||
clearPreviewChat: boolean
|
||||
configureData: ReturnType<typeof useAgentConfigureData>
|
||||
conversationIds: AgentConfigureConversationIds
|
||||
hideBuildDraftBarUntilRefresh: boolean
|
||||
isRefreshingDebugConversation: boolean
|
||||
isViewingVersion: boolean
|
||||
resetBuildChatSession: () => Promise<void>
|
||||
rightPanelMode: AgentConfigureRightPanelMode
|
||||
setClearPreviewChat: Dispatch<SetStateAction<boolean>>
|
||||
setConversationIds: Dispatch<SetStateAction<AgentConfigureConversationIds>>
|
||||
setHideBuildDraftBarUntilRefresh: Dispatch<SetStateAction<boolean>>
|
||||
setRightPanelMode: Dispatch<SetStateAction<AgentConfigureRightPanelMode>>
|
||||
setShowChatFeatures: Dispatch<SetStateAction<boolean>>
|
||||
setShowPreviewVersions: Dispatch<SetStateAction<boolean>>
|
||||
showChatFeatures: boolean
|
||||
showPreviewVersions: boolean
|
||||
workingDirectoryPanel: ReturnType<typeof useAgentWorkingDirectoryPanel>
|
||||
onComposerRebase: () => void
|
||||
onRefreshDebugConversation: () => void
|
||||
onRefreshDebugConversationAsync: () => Promise<unknown>
|
||||
onRefreshDebugConversation: (conversationId: string) => void
|
||||
onSelectVersion: (versionId: string | null) => void
|
||||
}) {
|
||||
const {
|
||||
@ -214,36 +266,8 @@ function AgentConfigurePageComposerContent({
|
||||
activeConfigSnapshot,
|
||||
agentSoulConfig,
|
||||
} = configureData
|
||||
const buildDraftActionsDisabled = useAtomValue(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const clearPreviewChat = useAtomValue(agentConfigureClearPreviewChatAtom)
|
||||
const conversationIds = useAtomValue(agentConfigureConversationIdsAtom)
|
||||
const rightPanelChatMode = useAtomValue(agentConfigureRightPanelChatModeAtom)
|
||||
const showChatFeatures = useAtomValue(agentConfigureShowChatFeaturesAtom)
|
||||
const showPreviewVersions = useAtomValue(agentConfigureShowPreviewVersionsAtom)
|
||||
const resetConversation = useSetAtom(resetAgentConfigureConversationAtom)
|
||||
const setBuildDraftActionsDisabled = useSetAtom(agentConfigureBuildDraftActionsDisabledAtom)
|
||||
const setClearPreviewChat = useSetAtom(agentConfigureClearPreviewChatAtom)
|
||||
const setConversationId = useSetAtom(setAgentConfigureConversationIdAtom)
|
||||
const setRightPanelMode = useSetAtom(agentConfigureRightPanelModeAtom)
|
||||
const setShowChatFeatures = useSetAtom(agentConfigureShowChatFeaturesAtom)
|
||||
const setShowPreviewVersions = useSetAtom(agentConfigureShowPreviewVersionsAtom)
|
||||
const rebaseComposerDraft = useSetAtom(rebaseAgentComposerDraftAtom)
|
||||
const showBuildDraftBar = buildDraft.isActive
|
||||
const resetBuildChatSession = useCallback(async () => {
|
||||
try {
|
||||
await onRefreshDebugConversationAsync()
|
||||
}
|
||||
finally {
|
||||
setConversationId({ mode: 'build', conversationId: null })
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
}, [onRefreshDebugConversationAsync, setClearPreviewChat, setConversationId])
|
||||
const rebaseComposerDraftFromSoulConfig = useCallback((agentSoulConfig?: AgentSoulConfig) => {
|
||||
rebaseComposerDraft({
|
||||
draft: agentSoulConfigToFormState(agentSoulConfig),
|
||||
originalConfig: agentSoulConfig,
|
||||
})
|
||||
}, [rebaseComposerDraft])
|
||||
const rightPanelChatMode: AgentConfigureRightPanelMode = rightPanelMode === 'preview' ? 'build' : rightPanelMode
|
||||
const showBuildDraftBar = buildDraft.isActive && !hideBuildDraftBarUntilRefresh
|
||||
const {
|
||||
currentModel,
|
||||
setConfigureModel,
|
||||
@ -263,8 +287,6 @@ function AgentConfigurePageComposerContent({
|
||||
const buildDraftActions = useAgentConfigureBuildDraftActions({
|
||||
agentId,
|
||||
isActive: buildDraft.isActive,
|
||||
normalAgentSoulConfig: agentSoulConfig,
|
||||
rebaseComposerDraft: rebaseComposerDraftFromSoulConfig,
|
||||
refetchBuildDraft: buildDraft.refetch,
|
||||
refetchComposer: composerQuery.refetch,
|
||||
resetBuildChatSession,
|
||||
@ -273,28 +295,23 @@ function AgentConfigurePageComposerContent({
|
||||
setSoulSourceOverride: buildDraft.setSoulSourceOverride,
|
||||
})
|
||||
const selectVersion = useCallback((versionId: string | null) => {
|
||||
buildDraft.setSoulSourceOverride(versionId ? 'view-version' : null)
|
||||
onSelectVersion(versionId)
|
||||
}, [onSelectVersion])
|
||||
const hasRestartCurrentChatTarget = !!conversationIds[rightPanelChatMode] || (rightPanelChatMode === 'build' && buildDraft.isActive)
|
||||
const isRestartCurrentChatDisabled = !hasRestartCurrentChatTarget
|
||||
|| buildDraftActionsDisabled
|
||||
|| isRefreshingDebugConversation
|
||||
|| buildDraftActions.isApplyingBuildDraft
|
||||
|| buildDraftActions.isDiscardingBuildDraft
|
||||
const isChatFeaturesReadOnly = isViewingVersion || buildDraft.isActive
|
||||
}, [buildDraft, onSelectVersion])
|
||||
const restartCurrentChat = () => {
|
||||
if (isRestartCurrentChatDisabled)
|
||||
return
|
||||
|
||||
if (rightPanelChatMode === 'build' && buildDraft.isActive) {
|
||||
void buildDraftActions.discardBuildDraft()
|
||||
return
|
||||
}
|
||||
|
||||
if (rightPanelChatMode === 'build')
|
||||
onRefreshDebugConversation()
|
||||
onRefreshDebugConversation(conversationIds.build ?? '')
|
||||
|
||||
resetConversation(rightPanelChatMode)
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
[rightPanelChatMode]: null,
|
||||
}))
|
||||
setClearPreviewChat(true)
|
||||
}
|
||||
|
||||
return (
|
||||
@ -319,7 +336,6 @@ function AgentConfigurePageComposerContent({
|
||||
? (
|
||||
<AgentBuildDraftBar
|
||||
changesCount={buildDraft.changesCount}
|
||||
disabled={buildDraftActionsDisabled}
|
||||
isApplying={buildDraftActions.isApplyingBuildDraft}
|
||||
isDiscarding={buildDraftActions.isDiscardingBuildDraft}
|
||||
onApply={() => {
|
||||
@ -355,7 +371,7 @@ function AgentConfigurePageComposerContent({
|
||||
workingDirectoryPanel.openWorkingDirectory()
|
||||
}}
|
||||
onRefresh={restartCurrentChat}
|
||||
refreshDisabled={isRestartCurrentChatDisabled}
|
||||
refreshDisabled={isRefreshingDebugConversation || buildDraftActions.isDiscardingBuildDraft}
|
||||
/>
|
||||
)}
|
||||
chat={(
|
||||
@ -373,27 +389,20 @@ function AgentConfigurePageComposerContent({
|
||||
onClearChatListChange={setClearPreviewChat}
|
||||
onConversationComplete={(mode) => {
|
||||
if (mode === 'build')
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setBuildDraftActionsDisabled(false))
|
||||
buildDraftActions.refreshBuildDraftAfterBuildChat(() => setHideBuildDraftBarUntilRefresh(false))
|
||||
}}
|
||||
onConversationIdChange={(mode, conversationId) => {
|
||||
setConversationId({ mode, conversationId })
|
||||
setConversationIds(current => ({
|
||||
...current,
|
||||
[mode]: conversationId,
|
||||
}))
|
||||
}}
|
||||
onSaveDraftBeforeRun={rightPanelChatMode === 'build'
|
||||
? async () => {
|
||||
setBuildDraftActionsDisabled(true)
|
||||
try {
|
||||
return await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
}
|
||||
catch (error) {
|
||||
setBuildDraftActionsDisabled(false)
|
||||
throw error
|
||||
}
|
||||
setHideBuildDraftBarUntilRefresh(true)
|
||||
await buildDraftActions.prepareBuildDraftBeforeRun()
|
||||
}
|
||||
: saveDraft}
|
||||
onSendInterrupted={() => {
|
||||
if (rightPanelChatMode === 'build')
|
||||
setBuildDraftActionsDisabled(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -411,8 +420,8 @@ function AgentConfigurePageComposerContent({
|
||||
{workingDirectoryPanel.panel}
|
||||
<AgentChatFeaturesPanel
|
||||
show={showChatFeatures}
|
||||
appFeatures={buildDraft.agentSoulConfig?.app_features}
|
||||
disabled={versionQuery.isPending || isChatFeaturesReadOnly}
|
||||
appFeatures={agentSoulConfig?.app_features}
|
||||
disabled={versionQuery.isPending}
|
||||
onClose={() => setShowChatFeatures(false)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { AgentBuildDraftBar } from '../build-draft-bar'
|
||||
|
||||
describe('AgentBuildDraftBar', () => {
|
||||
it('should disable both build draft actions when the bar is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onApply = vi.fn()
|
||||
const onDiscard = vi.fn()
|
||||
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
disabled
|
||||
onApply={onApply}
|
||||
onDiscard={onDiscard}
|
||||
/>,
|
||||
)
|
||||
|
||||
const applyButton = screen.getByRole('button', { name: 'custom.apply' })
|
||||
const discardButton = screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })
|
||||
|
||||
expect(applyButton).toBeDisabled()
|
||||
expect(discardButton).toBeDisabled()
|
||||
|
||||
await user.click(applyButton)
|
||||
await user.click(discardButton)
|
||||
|
||||
expect(onApply).not.toHaveBeenCalled()
|
||||
expect(onDiscard).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should keep discard disabled while apply is pending', () => {
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
isApplying
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'custom.apply' })).toHaveAttribute('aria-disabled', 'true')
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should keep apply disabled while discard is pending', () => {
|
||||
render(
|
||||
<AgentBuildDraftBar
|
||||
changesCount={1}
|
||||
isDiscarding
|
||||
onApply={vi.fn()}
|
||||
onDiscard={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'custom.apply' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.buildDraft.discard' })).toHaveAttribute('aria-disabled', 'true')
|
||||
})
|
||||
})
|
||||
@ -346,17 +346,17 @@ describe('AgentConfigurePublishBar', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should show unpublished state from local draft changes even when active config is published', () => {
|
||||
it('should keep published state from active config status even when local draft differs', () => {
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot: null,
|
||||
prompt: 'Updated system prompt',
|
||||
})
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.unpublishedChanges')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled()
|
||||
expect(hotkeyRegistrations.get('Mod+Shift+P')?.options).toEqual(
|
||||
expect.objectContaining({ enabled: true, ignoreInputs: false }),
|
||||
expect.objectContaining({ enabled: false, ignoreInputs: false }),
|
||||
)
|
||||
})
|
||||
|
||||
@ -437,31 +437,6 @@ describe('AgentConfigurePublishBar', () => {
|
||||
expect(screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trust backend published state after autosave confirms the draft matches the active snapshot', () => {
|
||||
const stalePublishedDraftBaseline = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Old unpublished normal draft',
|
||||
}
|
||||
const savedDraftMatchingActiveSnapshot = {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Published prompt',
|
||||
}
|
||||
|
||||
renderPublishBar({
|
||||
activeConfigIsPublished: true,
|
||||
activeConfigSnapshot,
|
||||
setupStore: (store) => {
|
||||
store.set(agentComposerPublishedDraftAtom, stalePublishedDraftBaseline)
|
||||
store.set(agentComposerOriginalDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
store.set(agentComposerDraftAtom, savedDraftMatchingActiveSnapshot)
|
||||
},
|
||||
})
|
||||
|
||||
expect(screen.getByText('agentV2.agentDetail.configure.publishBar.upToDate')).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.publishBar.published' })).toBeDisabled()
|
||||
expect(screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.publishBar\.publishUpdate/ })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render publishing as a single disabled action state', () => {
|
||||
renderPublishBar({ isPublishing: true, prompt: 'Updated system prompt' })
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
|
||||
|
||||
type AgentBuildDraftBarProps = {
|
||||
changesCount: number
|
||||
disabled?: boolean
|
||||
isApplying?: boolean
|
||||
isDiscarding?: boolean
|
||||
onApply: () => void
|
||||
@ -14,7 +13,6 @@ type AgentBuildDraftBarProps = {
|
||||
|
||||
export function AgentBuildDraftBar({
|
||||
changesCount,
|
||||
disabled = false,
|
||||
isApplying = false,
|
||||
isDiscarding = false,
|
||||
onApply,
|
||||
@ -22,11 +20,10 @@ export function AgentBuildDraftBar({
|
||||
}: AgentBuildDraftBarProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const { t: tCustom } = useTranslation('custom')
|
||||
const isPending = isApplying || isDiscarding
|
||||
const metaLabel = changesCount > 0
|
||||
? t('agentDetail.configure.buildDraft.changes', { count: changesCount })
|
||||
: t('agentDetail.configure.buildDraft.noChanges')
|
||||
const applyDisabled = disabled || isDiscarding
|
||||
const discardDisabled = disabled || isApplying
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex max-w-full min-w-0 items-center gap-2 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur py-2 pr-2 pl-4 shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]">
|
||||
@ -42,7 +39,7 @@ export function AgentBuildDraftBar({
|
||||
type="button"
|
||||
variant="primary"
|
||||
loading={isApplying}
|
||||
disabled={applyDisabled}
|
||||
disabled={isPending}
|
||||
className="h-8 rounded-lg px-3"
|
||||
onClick={onApply}
|
||||
>
|
||||
@ -52,7 +49,7 @@ export function AgentBuildDraftBar({
|
||||
type="button"
|
||||
variant="secondary"
|
||||
loading={isDiscarding}
|
||||
disabled={discardDisabled}
|
||||
disabled={isPending}
|
||||
className="h-8 rounded-lg px-3"
|
||||
onClick={onDiscard}
|
||||
>
|
||||
|
||||
@ -13,7 +13,7 @@ import { useAtomValue } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKnowledgeValidationMessage, validateKnowledgeRetrievals } from '@/features/agent-v2/agent-composer/knowledge-validation'
|
||||
import { hasAgentComposerUnpublishedChangesAtom, isAgentComposerDirtyAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { hasAgentComposerUnpublishedChangesAtom } from '@/features/agent-v2/agent-composer/store'
|
||||
import { agentComposerKnowledgeRetrievalsAtom } from '@/features/agent-v2/agent-composer/store-modules/knowledge'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
@ -43,26 +43,21 @@ type AgentConfigurePublishBarProps = {
|
||||
function getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isDirty,
|
||||
isPublishing,
|
||||
}: {
|
||||
activeConfigIsPublished?: boolean
|
||||
activeConfigSnapshot?: AgentConfigSnapshotSummaryResponse | null
|
||||
hasLocalChanges: boolean
|
||||
hasUnpublishedChanges: boolean
|
||||
isDirty: boolean
|
||||
isPublishing: boolean
|
||||
}): AgentConfigurePublishState {
|
||||
if (isPublishing)
|
||||
return 'publishing'
|
||||
|
||||
if (hasLocalChanges)
|
||||
return 'unpublished'
|
||||
|
||||
if (activeConfigIsPublished)
|
||||
return 'published'
|
||||
|
||||
if (hasUnpublishedChanges)
|
||||
if (isDirty)
|
||||
return 'unpublished'
|
||||
|
||||
if (!activeConfigSnapshot)
|
||||
@ -102,22 +97,19 @@ export function AgentConfigurePublishBar({
|
||||
const queryClient = useQueryClient()
|
||||
const [publishBarMode, setPublishBarMode] = useState<PublishBarMode>({ status: 'compact' })
|
||||
const hasUnpublishedChanges = useAtomValue(hasAgentComposerUnpublishedChangesAtom)
|
||||
const hasLocalChanges = useAtomValue(isAgentComposerDirtyAtom)
|
||||
const knowledgeRetrievals = useAtomValue(agentComposerKnowledgeRetrievalsAtom)
|
||||
const knowledgeValidation = validateKnowledgeRetrievals(knowledgeRetrievals)
|
||||
const getValidationMessage = useKnowledgeValidationMessage()
|
||||
const publishableState = getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isDirty: hasUnpublishedChanges,
|
||||
isPublishing: false,
|
||||
})
|
||||
const publishState = getPublishState({
|
||||
activeConfigIsPublished,
|
||||
activeConfigSnapshot,
|
||||
hasLocalChanges,
|
||||
hasUnpublishedChanges,
|
||||
isDirty: hasUnpublishedChanges,
|
||||
isPublishing,
|
||||
})
|
||||
const publishIsAvailable = !isPublishing && (publishableState === 'draft' || publishableState === 'unpublished')
|
||||
|
||||
@ -235,7 +235,6 @@ export function AgentSkillDetailDialog({
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain outline-none focus-visible:outline-none mask-linear-[to_bottom,transparent_0,black_min(40px,var(--scroll-area-overflow-y-start)),black_calc(100%_-_min(40px,var(--scroll-area-overflow-y-end,40px))),transparent_100%] mask-no-repeat',
|
||||
content: 'flex min-h-full w-full max-w-full min-w-0 flex-col gap-2 px-6 pt-4 pb-0',
|
||||
scrollbar: 'data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1',
|
||||
}}
|
||||
>
|
||||
{detail.filePreview && (
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import type { ComponentProps } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { createStore, Provider as JotaiProvider } from 'jotai'
|
||||
import { useState } from 'react'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { agentComposerModelAtom } from '@/features/agent-v2/agent-composer/store-modules/model'
|
||||
import { agentComposerPromptAtom } from '@/features/agent-v2/agent-composer/store-modules/prompt'
|
||||
@ -14,43 +13,27 @@ const handleSendMock = vi.hoisted(() => vi.fn())
|
||||
const stopCallbackRef = vi.hoisted(() => ({
|
||||
current: undefined as undefined | ((taskId: string) => void),
|
||||
}))
|
||||
const sendResultRef = vi.hoisted(() => ({
|
||||
current: undefined as unknown,
|
||||
}))
|
||||
const chatMessagesGetMock = vi.hoisted(() => vi.fn())
|
||||
const suggestedQuestionsGetMock = vi.hoisted(() => vi.fn())
|
||||
const stopPostMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const { useState } = await import('react')
|
||||
|
||||
return {
|
||||
default: () => function MockChat(props: {
|
||||
onSend: (message: string) => unknown
|
||||
onStopResponding: () => void
|
||||
}) {
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span>{`sessionSent:${sent ? 'yes' : 'no'}`}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSent(true)
|
||||
sendResultRef.current = props.onSend('hello')
|
||||
}}
|
||||
>
|
||||
send
|
||||
</button>
|
||||
<button type="button" onClick={props.onStopResponding}>
|
||||
stop
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}
|
||||
})
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: () => function MockChat(props: {
|
||||
onSend: (message: string) => void
|
||||
onStopResponding: () => void
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<button type="button" onClick={() => props.onSend('hello')}>
|
||||
send
|
||||
</button>
|
||||
<button type="button" onClick={props.onStopResponding}>
|
||||
stop
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/chat/chat/hooks', () => ({
|
||||
useChat: useChatMock.mockImplementation((
|
||||
@ -153,93 +136,6 @@ function renderPreviewChat(props?: Partial<ComponentProps<typeof AgentChatRuntim
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeConversationHarness() {
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={false}
|
||||
conversationId={conversationId}
|
||||
inputPlaceholder="Message agent"
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={vi.fn()}
|
||||
onConversationIdChange={setConversationId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RuntimeClearCommandHarness({
|
||||
inputPlaceholder,
|
||||
}: {
|
||||
inputPlaceholder: string
|
||||
}) {
|
||||
const [clearChatList, setClearChatList] = useState(true)
|
||||
|
||||
return (
|
||||
<AgentChatRuntime
|
||||
agentId="agent-1"
|
||||
clearChatList={clearChatList}
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
renderEmptyState={() => null}
|
||||
onClearChatListChange={setClearChatList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewChatWithConversationHarness() {
|
||||
const store = createStore()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
store.set(agentComposerModelAtom, {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<RuntimeConversationHarness />
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
function renderPreviewChatWithClearCommandHarness() {
|
||||
const store = createStore()
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
store.set(agentComposerModelAtom, {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
})
|
||||
store.set(agentComposerPromptAtom, 'You are helpful.')
|
||||
|
||||
const renderHarness = (inputPlaceholder: string) => (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider store={store}>
|
||||
<RuntimeClearCommandHarness inputPlaceholder={inputPlaceholder} />
|
||||
</JotaiProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
|
||||
return {
|
||||
...render(renderHarness('Message agent')),
|
||||
renderHarness,
|
||||
}
|
||||
}
|
||||
|
||||
describe('AgentPreviewChat', () => {
|
||||
beforeEach(() => {
|
||||
useChatMock.mockClear()
|
||||
@ -248,7 +144,6 @@ describe('AgentPreviewChat', () => {
|
||||
suggestedQuestionsGetMock.mockResolvedValue({ data: [] })
|
||||
stopPostMock.mockResolvedValue({ result: 'success' })
|
||||
stopCallbackRef.current = undefined
|
||||
sendResultRef.current = undefined
|
||||
})
|
||||
|
||||
it('should initialize preview chat with the stable debug conversation history', async () => {
|
||||
@ -363,60 +258,6 @@ describe('AgentPreviewChat', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify the owner when a send settles with an error', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
act(() => {
|
||||
callbacks.onSendSettled(true)
|
||||
})
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify the owner when stopping a responding send', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop' }))
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
expect(stopPostMock).toHaveBeenCalledWith({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
task_id: 'task-1',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify the owner once when a stopped send later settles with an error', async () => {
|
||||
const onSendInterrupted = vi.fn()
|
||||
renderPreviewChat({
|
||||
onSendInterrupted,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'stop' }))
|
||||
act(() => {
|
||||
callbacks.onSendSettled(true)
|
||||
})
|
||||
|
||||
expect(onSendInterrupted).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not send preview chat when draft save fails', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockRejectedValue(new Error('save failed'))
|
||||
renderPreviewChat({
|
||||
@ -426,7 +267,6 @@ describe('AgentPreviewChat', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(saveDraftBeforeRun).toHaveBeenCalledTimes(1))
|
||||
await expect(sendResultRef.current).resolves.toBe(false)
|
||||
expect(handleSendMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -447,92 +287,6 @@ describe('AgentPreviewChat', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should send build chat inputs from the prepared build draft snapshot', async () => {
|
||||
const saveDraftBeforeRun = vi.fn().mockResolvedValue({
|
||||
app_variables: [
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text-input',
|
||||
default: 'Paris',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
model: {
|
||||
model_provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
},
|
||||
prompt: {
|
||||
system_prompt: 'Build draft prompt',
|
||||
},
|
||||
})
|
||||
renderPreviewChat({
|
||||
agentSoulConfig: {
|
||||
app_variables: [
|
||||
{
|
||||
name: 'city',
|
||||
type: 'text-input',
|
||||
default: 'London',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
draftType: 'debug_build',
|
||||
onSaveDraftBeforeRun: saveDraftBeforeRun,
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
expect(handleSendMock).toHaveBeenCalledWith(
|
||||
'agent/agent-1/chat-messages',
|
||||
expect.objectContaining({
|
||||
draft_type: 'debug_build',
|
||||
inputs: {
|
||||
city: 'Paris',
|
||||
},
|
||||
overrideInputsForm: [
|
||||
expect.objectContaining({
|
||||
variable: 'city',
|
||||
default: 'Paris',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
)
|
||||
})
|
||||
|
||||
it('should keep the current chat session visible when a sent message creates a conversation', async () => {
|
||||
chatMessagesGetMock.mockReturnValue(new Promise(() => undefined))
|
||||
renderPreviewChatWithConversationHarness()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'send' }))
|
||||
|
||||
await waitFor(() => expect(handleSendMock).toHaveBeenCalledTimes(1))
|
||||
const callbacks = handleSendMock.mock.calls.at(0)?.[2]
|
||||
|
||||
await act(async () => {
|
||||
callbacks.onConversationComplete('conversation-created-by-send')
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'send' })).toBeInTheDocument()
|
||||
expect(screen.getByText('sessionSent:yes')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should keep the reset command acknowledgement stable while clear chat is pending', async () => {
|
||||
const { renderHarness, rerender } = renderPreviewChatWithClearCommandHarness()
|
||||
|
||||
await waitFor(() => expect(useChatMock).toHaveBeenCalled())
|
||||
const firstResetAcknowledgement = useChatMock.mock.calls.at(-1)?.[5]
|
||||
|
||||
rerender(renderHarness('Message agent again'))
|
||||
|
||||
await waitFor(() => expect(useChatMock.mock.calls.length).toBeGreaterThan(1))
|
||||
const secondResetAcknowledgement = useChatMock.mock.calls.at(-1)?.[5]
|
||||
|
||||
expect(secondResetAcknowledgement).toBe(firstResetAcknowledgement)
|
||||
})
|
||||
|
||||
it('should keep preview file upload disabled by default', async () => {
|
||||
renderPreviewChat()
|
||||
|
||||
|
||||
@ -9,7 +9,6 @@ function renderHeader({
|
||||
onToggleChatFeatures = vi.fn(),
|
||||
onOpenWorkingDirectory = vi.fn(),
|
||||
onRefresh = vi.fn(),
|
||||
refreshDisabled = false,
|
||||
}: {
|
||||
mode?: 'build' | 'preview'
|
||||
previewEnabled?: boolean
|
||||
@ -17,7 +16,6 @@ function renderHeader({
|
||||
onToggleChatFeatures?: () => void
|
||||
onOpenWorkingDirectory?: () => void
|
||||
onRefresh?: () => void
|
||||
refreshDisabled?: boolean
|
||||
} = {}) {
|
||||
render(
|
||||
<AgentPreviewHeader
|
||||
@ -28,7 +26,6 @@ function renderHeader({
|
||||
onToggleChatFeatures={onToggleChatFeatures}
|
||||
onOpenWorkingDirectory={onOpenWorkingDirectory}
|
||||
onRefresh={onRefresh}
|
||||
refreshDisabled={refreshDisabled}
|
||||
/>,
|
||||
)
|
||||
}
|
||||
@ -48,16 +45,6 @@ describe('AgentPreviewHeader', () => {
|
||||
expect(onRefresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not emit refresh when the restart button is disabled', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onRefresh = vi.fn()
|
||||
renderHeader({ mode: 'build', onRefresh, refreshDisabled: true })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.preview.restart' }))
|
||||
|
||||
expect(onRefresh).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show chat features in build mode', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onToggleChatFeatures = vi.fn()
|
||||
|
||||
@ -80,15 +80,12 @@ function AgentChatFeaturesPanelContent({
|
||||
const featuresStore = useFeaturesStore()
|
||||
const setAppFeatures = useSetAppFeatures()
|
||||
const handleChange = useCallback(() => {
|
||||
if (disabled)
|
||||
return
|
||||
|
||||
const features = featuresStore?.getState().features
|
||||
if (!features)
|
||||
return
|
||||
|
||||
setAppFeatures(currentAppFeatures => toAppFeatures(features, currentAppFeatures ?? appFeatures))
|
||||
}, [appFeatures, disabled, featuresStore, setAppFeatures])
|
||||
}, [appFeatures, featuresStore, setAppFeatures])
|
||||
|
||||
return (
|
||||
<NewFeaturePanel
|
||||
|
||||
@ -11,7 +11,7 @@ import type {
|
||||
import type {
|
||||
ReactNode,
|
||||
} from 'react'
|
||||
import type { FeedbackType, IChatItem, InputForm, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { FeedbackType, IChatItem, ThoughtItem } from '@/app/components/base/chat/chat/type'
|
||||
import type { ChatConfig, ChatItem, ChatItemInTree, OnSend } from '@/app/components/base/chat/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
@ -23,7 +23,7 @@ import { Avatar } from '@langgenius/dify-ui/avatar'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import ChatInputArea from '@/app/components/base/chat/chat/chat-input-area'
|
||||
import { useChat } from '@/app/components/base/chat/chat/hooks'
|
||||
import { buildChatItemTree, getLastAnswer, isValidGeneratedAnswer } from '@/app/components/base/chat/utils'
|
||||
@ -155,15 +155,6 @@ const toInputForm = (variable: NonNullable<AgentSoulConfig['app_variables']>[num
|
||||
}
|
||||
}
|
||||
|
||||
const getAgentSoulInputsForm = (agentSoulConfig?: AgentSoulConfig) => (agentSoulConfig?.app_variables ?? []).map(toInputForm)
|
||||
|
||||
const getAgentSoulInputs = (inputsForm: InputForm[]) => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const toAgentTool = (tool: AgentSoulDifyToolConfig) => ({
|
||||
provider_id: tool.provider_id ?? tool.provider ?? tool.plugin_id ?? '',
|
||||
provider_type: tool.provider_type ?? 'builtin',
|
||||
@ -454,8 +445,7 @@ export type AgentChatRuntimeProps = {
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}
|
||||
|
||||
export function AgentChatRuntime({
|
||||
@ -474,40 +464,29 @@ export function AgentChatRuntime({
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: AgentChatRuntimeProps) {
|
||||
const [currentSessionConversationId, setCurrentSessionConversationId] = useState<string | null>(null)
|
||||
const handleClearChatListChange = useCallback((nextClearChatList: boolean) => {
|
||||
if (!nextClearChatList)
|
||||
setCurrentSessionConversationId(null)
|
||||
onClearChatListChange(nextClearChatList)
|
||||
}, [onClearChatListChange])
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ['agent-chat-conversation-messages', agentId, conversationId],
|
||||
queryFn: () => fetchAgentConversationMessages(agentId, conversationId!),
|
||||
enabled: !!conversationId,
|
||||
})
|
||||
const conversationBelongsToCurrentSession = !!conversationId && conversationId === currentSessionConversationId
|
||||
const initialChatTree = useMemo(
|
||||
() => getFormattedAgentDebugChatTree(historyQuery.data?.data ?? []),
|
||||
[historyQuery.data?.data],
|
||||
)
|
||||
|
||||
if (conversationId && historyQuery.isPending && !conversationBelongsToCurrentSession) {
|
||||
if (conversationId && historyQuery.isPending) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Loading type="app" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chatSessionKey = !conversationId || conversationBelongsToCurrentSession
|
||||
? 'current-session'
|
||||
: `${conversationId}-${historyQuery.dataUpdatedAt}`
|
||||
|
||||
return (
|
||||
<AgentPreviewChatSession
|
||||
key={chatSessionKey}
|
||||
key={`${conversationId ?? 'new'}-${historyQuery.dataUpdatedAt}`}
|
||||
agentId={agentId}
|
||||
agentIcon={agentIcon}
|
||||
agentIconBackground={agentIconBackground}
|
||||
@ -521,11 +500,9 @@ export function AgentChatRuntime({
|
||||
inputPlaceholder={inputPlaceholder}
|
||||
sendButtonLabel={sendButtonLabel}
|
||||
renderEmptyState={renderEmptyState}
|
||||
onClearChatListChange={handleClearChatListChange}
|
||||
onClearChatListChange={onClearChatListChange}
|
||||
onConversationComplete={onConversationComplete}
|
||||
onConversationIdChange={onConversationIdChange}
|
||||
onCurrentSessionConversationIdChange={setCurrentSessionConversationId}
|
||||
onSendInterrupted={onSendInterrupted}
|
||||
onSaveDraftBeforeRun={onSaveDraftBeforeRun}
|
||||
/>
|
||||
)
|
||||
@ -548,8 +525,6 @@ function AgentPreviewChatSession({
|
||||
onClearChatListChange,
|
||||
onConversationComplete,
|
||||
onConversationIdChange,
|
||||
onCurrentSessionConversationIdChange,
|
||||
onSendInterrupted,
|
||||
onSaveDraftBeforeRun,
|
||||
}: {
|
||||
agentId: string
|
||||
@ -568,9 +543,7 @@ function AgentPreviewChatSession({
|
||||
onClearChatListChange: (clearChatList: boolean) => void
|
||||
onConversationComplete?: (conversationId: string) => void
|
||||
onConversationIdChange?: (conversationId: string) => void
|
||||
onCurrentSessionConversationIdChange: (conversationId: string) => void
|
||||
onSaveDraftBeforeRun?: () => Promise<AgentSoulConfig | void>
|
||||
onSendInterrupted?: () => void
|
||||
onSaveDraftBeforeRun?: () => Promise<void>
|
||||
}) {
|
||||
const { userProfile } = useAppContext()
|
||||
const prompt = useAtomValue(agentComposerPromptAtom)
|
||||
@ -580,16 +553,13 @@ function AgentPreviewChatSession({
|
||||
currentModel,
|
||||
prompt,
|
||||
}), [agentSoulConfig, currentModel, prompt])
|
||||
const inputsForm = useMemo(() => getAgentSoulInputsForm(agentSoulConfig), [agentSoulConfig])
|
||||
const inputs = useMemo(() => getAgentSoulInputs(inputsForm), [inputsForm])
|
||||
const sendInterruptedRef = useRef(false)
|
||||
const notifySendInterrupted = useCallback(() => {
|
||||
if (sendInterruptedRef.current)
|
||||
return
|
||||
|
||||
sendInterruptedRef.current = true
|
||||
onSendInterrupted?.()
|
||||
}, [onSendInterrupted])
|
||||
const inputsForm = useMemo(() => (agentSoulConfig?.app_variables ?? []).map(toInputForm), [agentSoulConfig?.app_variables])
|
||||
const inputs = useMemo(() => {
|
||||
return inputsForm.reduce<Inputs>((acc, input) => {
|
||||
acc[input.variable] = (input.default ?? '') as Inputs[string]
|
||||
return acc
|
||||
}, {})
|
||||
}, [inputsForm])
|
||||
const {
|
||||
textGenerationModelList,
|
||||
} = useTextGenerationCurrentProviderAndModelAndModelList(currentModel)
|
||||
@ -619,64 +589,40 @@ function AgentPreviewChatSession({
|
||||
)
|
||||
|
||||
const doSend: OnSend = useCallback(async (message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
sendInterruptedRef.current = false
|
||||
|
||||
try {
|
||||
const preparedAgentSoulConfig = await onSaveDraftBeforeRun?.()
|
||||
const runtimeAgentSoulConfig = preparedAgentSoulConfig || agentSoulConfig
|
||||
const runtimeInputsForm = preparedAgentSoulConfig ? getAgentSoulInputsForm(runtimeAgentSoulConfig) : inputsForm
|
||||
const runtimeInputs = preparedAgentSoulConfig ? getAgentSoulInputs(runtimeInputsForm) : inputs
|
||||
const runtimeConfig = preparedAgentSoulConfig
|
||||
? buildChatConfig({
|
||||
agentSoulConfig: runtimeAgentSoulConfig,
|
||||
currentModel: undefined,
|
||||
prompt: runtimeAgentSoulConfig?.prompt?.system_prompt ?? '',
|
||||
})
|
||||
: config
|
||||
|
||||
const currentProvider = textGenerationModelList.find(item => item.provider === runtimeConfig.model.provider)
|
||||
const selectedModel = currentProvider?.models.find(model => model.model === runtimeConfig.model.name)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs: runtimeInputs,
|
||||
overrideInputsForm: runtimeInputsForm,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType)
|
||||
data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
`agent/${agentId}/chat-messages`,
|
||||
data as Parameters<typeof handleSend>[1],
|
||||
{
|
||||
onGetConversationMessages: conversationId => fetchAgentConversationMessages(agentId, conversationId),
|
||||
onGetSuggestedQuestions: responseItemId => fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onConversationComplete: (completedConversationId) => {
|
||||
if (completedConversationId && completedConversationId !== conversationId)
|
||||
onCurrentSessionConversationIdChange(completedConversationId)
|
||||
onConversationIdChange?.(completedConversationId)
|
||||
onConversationComplete?.(completedConversationId)
|
||||
},
|
||||
onSendSettled: (hasError) => {
|
||||
if (hasError)
|
||||
notifySendInterrupted()
|
||||
},
|
||||
},
|
||||
)
|
||||
await onSaveDraftBeforeRun?.()
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
return
|
||||
}
|
||||
}, [agentId, agentSoulConfig, chatList, config, conversationId, draftType, handleSend, inputs, inputsForm, notifySendInterrupted, onConversationComplete, onConversationIdChange, onCurrentSessionConversationIdChange, onSaveDraftBeforeRun, textGenerationModelList])
|
||||
|
||||
const doStopResponding = useCallback(() => {
|
||||
handleStop()
|
||||
notifySendInterrupted()
|
||||
}, [handleStop, notifySendInterrupted])
|
||||
const currentProvider = textGenerationModelList.find(item => item.provider === config.model.provider)
|
||||
const selectedModel = currentProvider?.models.find(model => model.model === config.model.name)
|
||||
const supportVision = selectedModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
const data: Record<string, unknown> = {
|
||||
query: message,
|
||||
inputs,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
if (draftType)
|
||||
data.draft_type = draftType
|
||||
|
||||
if (files?.length && supportVision)
|
||||
data.files = files
|
||||
|
||||
handleSend(
|
||||
`agent/${agentId}/chat-messages`,
|
||||
data as Parameters<typeof handleSend>[1],
|
||||
{
|
||||
onGetConversationMessages: conversationId => fetchAgentConversationMessages(agentId, conversationId),
|
||||
onGetSuggestedQuestions: responseItemId => fetchAgentSuggestedQuestions(agentId, responseItemId),
|
||||
onConversationComplete: (conversationId) => {
|
||||
onConversationIdChange?.(conversationId)
|
||||
onConversationComplete?.(conversationId)
|
||||
},
|
||||
},
|
||||
)
|
||||
}, [agentId, chatList, config.model.name, config.model.provider, draftType, handleSend, inputs, onConversationComplete, onConversationIdChange, onSaveDraftBeforeRun, textGenerationModelList])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)
|
||||
@ -739,7 +685,7 @@ function AgentPreviewChatSession({
|
||||
inputsForm={inputsForm}
|
||||
onRegenerate={doRegenerate}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
onStopResponding={doStopResponding}
|
||||
onStopResponding={handleStop}
|
||||
noChatInput={isEmptyChat}
|
||||
showPromptLog
|
||||
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size="xl" />}
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureConversationIds, AgentConfigureRightPanelMode } from '../../state'
|
||||
import { useAgentPreviewSoulConfig } from '../../hooks'
|
||||
import { AgentBuildChat } from './build-chat'
|
||||
import { AgentPreviewChat } from './preview-chat'
|
||||
|
||||
export type AgentConfigureRightPanelMode = 'build' | 'preview'
|
||||
export type AgentConfigureConversationIds = Record<AgentConfigureRightPanelMode, string | null>
|
||||
|
||||
export function AgentConfigureRightPanelChat({
|
||||
agentSoulConfig,
|
||||
conversationIds,
|
||||
|
||||
@ -1,18 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { ScopeProvider } from 'jotai-scope'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AgentConfigureComposerScope } from './components/composer-session'
|
||||
import { AgentConfigurePageLoading } from './components/page-loading'
|
||||
import { useAgentConfigureData } from './hooks'
|
||||
import {
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureScopedAtoms,
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureSelectVersionAtom,
|
||||
rebaseAgentConfigureComposerAtom,
|
||||
} from './state'
|
||||
|
||||
type AgentConfigurePageProps = {
|
||||
agentId: string
|
||||
@ -22,13 +14,7 @@ export function AgentConfigurePage({
|
||||
agentId,
|
||||
}: AgentConfigurePageProps) {
|
||||
return (
|
||||
<ScopeProvider
|
||||
key={agentId}
|
||||
atoms={agentConfigureScopedAtoms}
|
||||
name="AgentConfigure"
|
||||
>
|
||||
<AgentConfigurePageContent agentId={agentId} />
|
||||
</ScopeProvider>
|
||||
<AgentConfigurePageContent agentId={agentId} />
|
||||
)
|
||||
}
|
||||
|
||||
@ -36,10 +22,8 @@ function AgentConfigurePageContent({
|
||||
agentId,
|
||||
}: AgentConfigurePageProps) {
|
||||
const { t } = useTranslation('agentV2')
|
||||
const selectedVersionId = useAtomValue(agentConfigureSelectedVersionIdAtom)
|
||||
const composerRebaseRevision = useAtomValue(agentConfigureComposerRebaseRevisionAtom)
|
||||
const rebaseComposer = useSetAtom(rebaseAgentConfigureComposerAtom)
|
||||
const selectVersion = useSetAtom(agentConfigureSelectVersionAtom)
|
||||
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null)
|
||||
const [composerRebaseRevision, setComposerRebaseRevision] = useState(0)
|
||||
const configureData = useAgentConfigureData(agentId, selectedVersionId)
|
||||
const isConfigureDataPending = configureData.agentQuery.isPending
|
||||
|| configureData.composerQuery.isPending
|
||||
@ -56,8 +40,8 @@ function AgentConfigurePageContent({
|
||||
agentId={agentId}
|
||||
composerRebaseRevision={composerRebaseRevision}
|
||||
configureData={configureData}
|
||||
onComposerRebase={rebaseComposer}
|
||||
onSelectVersion={selectVersion}
|
||||
onComposerRebase={() => setComposerRebaseRevision(revision => revision + 1)}
|
||||
onSelectVersion={setSelectedVersionId}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
import { atom } from 'jotai'
|
||||
|
||||
export type AgentConfigureRightPanelMode = 'build' | 'preview'
|
||||
export type AgentConfigureConversationIds = Record<AgentConfigureRightPanelMode, string | null>
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
export const agentConfigureSelectedVersionIdAtom = atom<string | null>(null)
|
||||
export const agentConfigureComposerRebaseRevisionAtom = atom(0)
|
||||
export const agentConfigureSoulSourceOverrideAtom = atom<AgentConfigureSoulSource | null>(null)
|
||||
|
||||
export const agentConfigureShowChatFeaturesAtom = atom(false)
|
||||
export const agentConfigureShowPreviewVersionsAtom = atom(false)
|
||||
export const agentConfigureClearPreviewChatAtom = atom(false)
|
||||
export const agentConfigureRightPanelModeAtom = atom<AgentConfigureRightPanelMode>('build')
|
||||
export const agentConfigureBuildDraftActionsDisabledAtom = atom(false)
|
||||
export const agentConfigureConversationIdsAtom = atom<AgentConfigureConversationIds>({
|
||||
build: null,
|
||||
preview: null,
|
||||
})
|
||||
|
||||
export const agentConfigureRightPanelChatModeAtom = atom((get): AgentConfigureRightPanelMode => {
|
||||
const mode = get(agentConfigureRightPanelModeAtom)
|
||||
|
||||
return mode === 'preview' ? 'build' : mode
|
||||
})
|
||||
|
||||
export const agentConfigureSelectVersionAtom = atom(null, (_get, set, versionId: string | null) => {
|
||||
set(agentConfigureSoulSourceOverrideAtom, versionId ? 'view-version' : null)
|
||||
set(agentConfigureSelectedVersionIdAtom, versionId)
|
||||
})
|
||||
|
||||
export const rebaseAgentConfigureComposerAtom = atom(null, (get, set) => {
|
||||
set(agentConfigureComposerRebaseRevisionAtom, get(agentConfigureComposerRebaseRevisionAtom) + 1)
|
||||
})
|
||||
|
||||
export const setAgentConfigureConversationIdAtom = atom(null, (get, set, {
|
||||
mode,
|
||||
conversationId,
|
||||
}: {
|
||||
mode: AgentConfigureRightPanelMode
|
||||
conversationId: string | null
|
||||
}) => {
|
||||
set(agentConfigureConversationIdsAtom, {
|
||||
...get(agentConfigureConversationIdsAtom),
|
||||
[mode]: conversationId,
|
||||
})
|
||||
})
|
||||
|
||||
export const resetAgentConfigureConversationAtom = atom(null, (get, set, mode: AgentConfigureRightPanelMode) => {
|
||||
set(agentConfigureConversationIdsAtom, {
|
||||
...get(agentConfigureConversationIdsAtom),
|
||||
[mode]: null,
|
||||
})
|
||||
set(agentConfigureClearPreviewChatAtom, true)
|
||||
})
|
||||
|
||||
export const agentConfigureScopedAtoms = [
|
||||
agentConfigureSelectedVersionIdAtom,
|
||||
agentConfigureComposerRebaseRevisionAtom,
|
||||
agentConfigureSoulSourceOverrideAtom,
|
||||
agentConfigureShowChatFeaturesAtom,
|
||||
agentConfigureShowPreviewVersionsAtom,
|
||||
agentConfigureClearPreviewChatAtom,
|
||||
agentConfigureRightPanelModeAtom,
|
||||
agentConfigureBuildDraftActionsDisabledAtom,
|
||||
agentConfigureConversationIdsAtom,
|
||||
] as const
|
||||
@ -1,21 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureSoulSource } from './state'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback } from 'react'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
export function usePrepareAgentBuildDraftBeforeRun({
|
||||
agentId,
|
||||
isBuildDraftActive,
|
||||
rebaseComposerDraft,
|
||||
saveDraft,
|
||||
setSoulSourceOverride,
|
||||
}: {
|
||||
agentId?: string
|
||||
isBuildDraftActive: boolean
|
||||
rebaseComposerDraft?: (agentSoulConfig?: AgentSoulConfig) => void
|
||||
saveDraft: () => Promise<unknown>
|
||||
setSoulSourceOverride?: (source: AgentConfigureSoulSource) => void
|
||||
}) {
|
||||
@ -46,10 +44,8 @@ export function usePrepareAgentBuildDraftBeforeRun({
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(buildDraftQueryOptions.queryKey, buildDraft)
|
||||
rebaseComposerDraft?.(buildDraft.agent_soul as AgentSoulConfig | undefined)
|
||||
setSoulSourceOverride?.('build-draft')
|
||||
return buildDraft.agent_soul as AgentSoulConfig | undefined
|
||||
}, [agentId, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isBuildDraftActive, queryClient, rebaseComposerDraft, saveDraft, setSoulSourceOverride])
|
||||
}, [agentId, buildDraftQueryOptions.queryKey, checkoutBuildDraft, isBuildDraftActive, queryClient, saveDraft, setSoulSourceOverride])
|
||||
|
||||
return {
|
||||
isCheckingOutBuildDraft,
|
||||
|
||||
@ -1,20 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentConfigureSoulSource } from './state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { agentSoulConfigToFormState } from '@/features/agent-v2/agent-composer/conversions'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { agentConfigureConsoleQuery } from './build-draft-query'
|
||||
import { usePrepareAgentBuildDraftBeforeRun } from './use-agent-build-draft-run'
|
||||
|
||||
export type AgentConfigureSoulSource = 'draft' | 'build-draft' | 'view-version'
|
||||
|
||||
const isNotFoundResponse = (error: unknown) => error instanceof Response && error.status === 404
|
||||
const getAgentSoulConfigFromRefetchResult = (result: unknown) => {
|
||||
return (result as { data?: { agent_soul?: AgentSoulConfig } } | undefined)?.data?.agent_soul
|
||||
}
|
||||
|
||||
export function useAgentConfigureBuildDraftData({
|
||||
agentId,
|
||||
@ -22,18 +20,15 @@ export function useAgentConfigureBuildDraftData({
|
||||
composerAgentSoulConfig,
|
||||
isViewingVersion,
|
||||
normalAgentSoulConfig,
|
||||
setSoulSourceOverride,
|
||||
soulSourceOverride,
|
||||
}: {
|
||||
agentId: string
|
||||
activeVersionId: string | null | undefined
|
||||
composerAgentSoulConfig?: AgentSoulConfig
|
||||
isViewingVersion: boolean
|
||||
normalAgentSoulConfig?: AgentSoulConfig
|
||||
setSoulSourceOverride: (source: AgentConfigureSoulSource | null) => void
|
||||
soulSourceOverride: AgentConfigureSoulSource | null
|
||||
}) {
|
||||
const shouldSilenceBuildDraftCheckRef = useRef(true)
|
||||
const [soulSourceOverride, setSoulSourceOverride] = useState<AgentConfigureSoulSource | null>(null)
|
||||
const buildDraftQueryInput = {
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
@ -73,8 +68,6 @@ export function useAgentConfigureBuildDraftData({
|
||||
}
|
||||
},
|
||||
retry: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
})
|
||||
const {
|
||||
data: buildDraftData,
|
||||
@ -118,8 +111,6 @@ export function useAgentConfigureBuildDraftData({
|
||||
export function useAgentConfigureBuildDraftActions({
|
||||
agentId,
|
||||
isActive,
|
||||
normalAgentSoulConfig,
|
||||
rebaseComposerDraft,
|
||||
refetchBuildDraft,
|
||||
refetchComposer,
|
||||
resetBuildChatSession,
|
||||
@ -129,8 +120,6 @@ export function useAgentConfigureBuildDraftActions({
|
||||
}: {
|
||||
agentId: string
|
||||
isActive: boolean
|
||||
normalAgentSoulConfig?: AgentSoulConfig
|
||||
rebaseComposerDraft: (agentSoulConfig?: AgentSoulConfig) => void
|
||||
refetchBuildDraft: () => Promise<unknown>
|
||||
refetchComposer: () => Promise<unknown>
|
||||
resetBuildChatSession: () => Promise<void>
|
||||
@ -141,7 +130,6 @@ export function useAgentConfigureBuildDraftActions({
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const queryClient = useQueryClient()
|
||||
const buildDraftRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const buildDraftRefreshGenerationRef = useRef(0)
|
||||
const buildDraftQueryOptions = consoleQuery.agent.byAgentId.buildDraft.get.queryOptions({
|
||||
input: {
|
||||
params: {
|
||||
@ -149,7 +137,6 @@ export function useAgentConfigureBuildDraftActions({
|
||||
},
|
||||
},
|
||||
})
|
||||
const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } })
|
||||
const applyBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.apply.post.mutationOptions())
|
||||
const discardBuildDraftMutation = useMutation(consoleQuery.agent.byAgentId.buildDraft.delete.mutationOptions())
|
||||
const { mutateAsync: applyBuildDraftRequest, isPending: isApplyingBuildDraft } = applyBuildDraftMutation
|
||||
@ -157,64 +144,32 @@ export function useAgentConfigureBuildDraftActions({
|
||||
const { prepareBuildDraftBeforeRun } = usePrepareAgentBuildDraftBeforeRun({
|
||||
agentId,
|
||||
isBuildDraftActive: isActive,
|
||||
rebaseComposerDraft,
|
||||
saveDraft,
|
||||
setSoulSourceOverride,
|
||||
})
|
||||
|
||||
const cancelBuildDraftRefresh = useCallback(() => {
|
||||
buildDraftRefreshGenerationRef.current += 1
|
||||
if (!buildDraftRefreshTimerRef.current)
|
||||
return
|
||||
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
buildDraftRefreshTimerRef.current = null
|
||||
}, [])
|
||||
|
||||
const prepareBuildDraftRun = useCallback(async () => {
|
||||
cancelBuildDraftRefresh()
|
||||
return prepareBuildDraftBeforeRun()
|
||||
}, [cancelBuildDraftRefresh, prepareBuildDraftBeforeRun])
|
||||
|
||||
const refreshBuildDraftAfterBuildChat = useCallback((onRefreshed?: () => void) => {
|
||||
cancelBuildDraftRefresh()
|
||||
const refreshGeneration = buildDraftRefreshGenerationRef.current
|
||||
if (buildDraftRefreshTimerRef.current)
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
|
||||
buildDraftRefreshTimerRef.current = setTimeout(async () => {
|
||||
buildDraftRefreshTimerRef.current = null
|
||||
try {
|
||||
const result = await refetchBuildDraft()
|
||||
if (refreshGeneration !== buildDraftRefreshGenerationRef.current)
|
||||
return
|
||||
|
||||
const agentSoulConfig = getAgentSoulConfigFromRefetchResult(result)
|
||||
if (agentSoulConfig)
|
||||
rebaseComposerDraft(agentSoulConfig)
|
||||
}
|
||||
catch {}
|
||||
finally {
|
||||
if (refreshGeneration === buildDraftRefreshGenerationRef.current)
|
||||
onRefreshed?.()
|
||||
}
|
||||
await refetchBuildDraft()
|
||||
onRefreshed?.()
|
||||
}, 1000)
|
||||
}, [cancelBuildDraftRefresh, rebaseComposerDraft, refetchBuildDraft])
|
||||
}, [refetchBuildDraft])
|
||||
|
||||
const exitBuildDraftMode = useCallback(async (shouldRefetchComposer: boolean) => {
|
||||
cancelBuildDraftRefresh()
|
||||
await resetBuildChatSession().catch(() => undefined)
|
||||
setSoulSourceOverride('draft')
|
||||
queryClient.removeQueries({
|
||||
queryKey: buildDraftQueryOptions.queryKey,
|
||||
})
|
||||
if (shouldRefetchComposer) {
|
||||
const result = await refetchComposer()
|
||||
rebaseComposerDraft(getAgentSoulConfigFromRefetchResult(result) ?? normalAgentSoulConfig)
|
||||
await refetchComposer()
|
||||
onComposerRebased?.()
|
||||
}
|
||||
else {
|
||||
rebaseComposerDraft(normalAgentSoulConfig)
|
||||
}
|
||||
}, [buildDraftQueryOptions.queryKey, cancelBuildDraftRefresh, normalAgentSoulConfig, onComposerRebased, queryClient, rebaseComposerDraft, refetchComposer, resetBuildChatSession, setSoulSourceOverride])
|
||||
}, [buildDraftQueryOptions.queryKey, onComposerRebased, queryClient, refetchComposer, resetBuildChatSession, setSoulSourceOverride])
|
||||
|
||||
const applyBuildDraft = async () => {
|
||||
try {
|
||||
@ -223,12 +178,6 @@ export function useAgentConfigureBuildDraftActions({
|
||||
agent_id: agentId,
|
||||
},
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: agentDetailQueryKey,
|
||||
})
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
await exitBuildDraftMode(true)
|
||||
toast.success(tCommon('api.actionSuccess'))
|
||||
}
|
||||
@ -254,17 +203,17 @@ export function useAgentConfigureBuildDraftActions({
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelBuildDraftRefresh()
|
||||
if (buildDraftRefreshTimerRef.current)
|
||||
clearTimeout(buildDraftRefreshTimerRef.current)
|
||||
}
|
||||
}, [cancelBuildDraftRefresh])
|
||||
}, [])
|
||||
|
||||
return {
|
||||
applyBuildDraft,
|
||||
cancelBuildDraftRefresh,
|
||||
discardBuildDraft,
|
||||
isApplyingBuildDraft,
|
||||
isDiscardingBuildDraft,
|
||||
prepareBuildDraftBeforeRun: prepareBuildDraftRun,
|
||||
prepareBuildDraftBeforeRun,
|
||||
refreshBuildDraftAfterBuildChat,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { AgentAppDetailWithSite, AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { AgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
import isEqual from 'fast-deep-equal'
|
||||
import { useSetAtom, useStore } from 'jotai'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -65,6 +64,21 @@ export function useAgentConfigureSync({
|
||||
currentModel: currentModelRef.current,
|
||||
}), [store])
|
||||
|
||||
const markActiveConfigUnpublished = useCallback(() => {
|
||||
queryClient.setQueryData<AgentAppDetailWithSite | undefined>(
|
||||
consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
(agentDetail) => {
|
||||
if (!agentDetail)
|
||||
return agentDetail
|
||||
|
||||
return {
|
||||
...agentDetail,
|
||||
active_config_is_published: false,
|
||||
}
|
||||
},
|
||||
)
|
||||
}, [agentId, queryClient])
|
||||
|
||||
const {
|
||||
mutateAsync: saveComposerDraft,
|
||||
} = useMutation(
|
||||
@ -80,16 +94,13 @@ export function useAgentConfigureSync({
|
||||
const saveComposer = useSerialAsyncCallback(async ({
|
||||
configSnapshot,
|
||||
draftBaseline,
|
||||
silent = true,
|
||||
}: {
|
||||
configSnapshot: AgentSoulConfig
|
||||
draftBaseline: AgentSoulConfigFormState
|
||||
silent?: boolean
|
||||
}) => {
|
||||
const savedDraftKey = JSON.stringify(configSnapshot)
|
||||
const agentDetailQueryKey = consoleQuery.agent.byAgentId.get.queryKey({ input: { params: { agent_id: agentId } } })
|
||||
try {
|
||||
const composerState = await saveComposerDraft({
|
||||
await saveComposerDraft({
|
||||
params: {
|
||||
agent_id: agentId,
|
||||
},
|
||||
@ -99,24 +110,13 @@ export function useAgentConfigureSync({
|
||||
agent_soul: configSnapshot,
|
||||
},
|
||||
})
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.agent.byAgentId.composer.get.queryKey({ input: { params: { agent_id: agentId } } }),
|
||||
composerState,
|
||||
)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: agentDetailQueryKey,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
// Autosave is silent and keeps the local draft intact; explicit commands must stop at this boundary.
|
||||
if (!silent) {
|
||||
toast.error(tCommon('api.actionFailed'))
|
||||
throw new Error('Failed to save agent composer draft.')
|
||||
}
|
||||
|
||||
// Draft sync follows workflow autosave behavior: save failures are silent and keep the local draft intact.
|
||||
return false
|
||||
}
|
||||
|
||||
markActiveConfigUnpublished()
|
||||
setOriginalDraft(draftBaseline)
|
||||
setDraftSavedAt(Date.now())
|
||||
lastAutosavedDraftKeyRef.current = savedDraftKey
|
||||
@ -146,16 +146,11 @@ export function useAgentConfigureSync({
|
||||
const draft = store.get(agentComposerDraftAtom)
|
||||
if (!validateKnowledgeRetrievals(draft.knowledgeRetrievals).isValid)
|
||||
throw new InvalidKnowledgeConfigurationError()
|
||||
const configSnapshot = getAgentSoulDraft()
|
||||
const hasEffectiveModelChange = !isEqual(configSnapshot.model, baseConfigRef.current?.model)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
if (!store.get(isAgentComposerDirtyAtom) && !hasEffectiveModelChange)
|
||||
return
|
||||
|
||||
debouncedSaveDraft.cancel?.()
|
||||
await saveComposer({
|
||||
configSnapshot,
|
||||
configSnapshot: getAgentSoulDraft(),
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, saveComposer, store])
|
||||
|
||||
@ -169,11 +164,11 @@ export function useAgentConfigureSync({
|
||||
!enabledRef.current
|
||||
|| !isDirty
|
||||
) {
|
||||
if (!isDirty)
|
||||
debouncedSaveDraft.cancel?.()
|
||||
return
|
||||
}
|
||||
|
||||
markActiveConfigUnpublished()
|
||||
|
||||
if (
|
||||
!validateKnowledgeRetrievals(store.get(agentComposerDraftAtom).knowledgeRetrievals).isValid
|
||||
|| lastAutosavedDraftKeyRef.current === agentSoulDraftKey
|
||||
@ -183,7 +178,7 @@ export function useAgentConfigureSync({
|
||||
|
||||
debouncedSaveDraft()
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, store])
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, markActiveConfigUnpublished, store])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -211,7 +206,6 @@ export function useAgentConfigureSync({
|
||||
const saved = await saveComposer({
|
||||
configSnapshot,
|
||||
draftBaseline: draft,
|
||||
silent: false,
|
||||
})
|
||||
if (!saved)
|
||||
return
|
||||
|
||||
@ -41,7 +41,7 @@ export function AgentLogsTable({
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-w-0 flex-col overflow-hidden">
|
||||
<div className="shrink-0">
|
||||
<div className="shrink-0 pr-3">
|
||||
<table aria-hidden="true" className="w-full table-fixed border-collapse">
|
||||
<LogsTableColGroup />
|
||||
<LogsTableHeader labels={tableHeaderLabels} />
|
||||
@ -55,7 +55,7 @@ export function AgentLogsTable({
|
||||
tabIndex={-1}
|
||||
className="overscroll-contain"
|
||||
>
|
||||
<ScrollAreaContent>
|
||||
<ScrollAreaContent className="pr-3">
|
||||
<table className="w-full table-fixed border-collapse">
|
||||
<LogsTableColGroup />
|
||||
<LogsTableHeader labels={tableHeaderLabels} rowClassName="sr-only" />
|
||||
@ -69,7 +69,7 @@ export function AgentLogsTable({
|
||||
</table>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="data-[orientation=vertical]:translate-x-1">
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
@ -139,7 +139,7 @@ export default function RosterPage() {
|
||||
/>
|
||||
</ScrollAreaContent>
|
||||
</ScrollAreaViewport>
|
||||
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
|
||||
<ScrollAreaScrollbar>
|
||||
<ScrollAreaThumb />
|
||||
</ScrollAreaScrollbar>
|
||||
</ScrollAreaRoot>
|
||||
|
||||
@ -167,7 +167,6 @@ export function GuideCard({ children, actions, contentScrollable = true }: {
|
||||
slotClassNames={{
|
||||
viewport: 'overscroll-contain',
|
||||
content: 'min-h-full pt-0.5 pb-6',
|
||||
scrollbar: 'data-[orientation=vertical]:-me-5 data-[orientation=vertical]:my-1',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@ -1,19 +1,5 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { handleStream, sseGet, ssePost } from './base'
|
||||
|
||||
const refreshAccessTokenOrReLoginMock = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('./refresh-token', () => ({
|
||||
refreshAccessTokenOrReLogin: refreshAccessTokenOrReLoginMock,
|
||||
}))
|
||||
import { handleStream } from './base'
|
||||
|
||||
describe('handleStream', () => {
|
||||
beforeEach(() => {
|
||||
@ -22,9 +8,11 @@ describe('handleStream', () => {
|
||||
|
||||
describe('Invalid response data handling', () => {
|
||||
it('should handle null bufferObj from JSON.parse gracefully', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
// Create a mock response that returns 'data: null'
|
||||
const mockReader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@ -44,10 +32,13 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('', true, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
@ -58,9 +49,11 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle non-object bufferObj from JSON.parse gracefully', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
// Create a mock response that returns a primitive value
|
||||
const mockReader = {
|
||||
read: vi.fn()
|
||||
.mockResolvedValueOnce({
|
||||
@ -80,10 +73,13 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('', true, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
@ -94,6 +90,7 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle valid message event correctly', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
@ -124,10 +121,13 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('Hello world', true, {
|
||||
conversationId: 'conv-123',
|
||||
taskId: 'task-456',
|
||||
@ -137,6 +137,7 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle error status 400 correctly', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
@ -165,10 +166,13 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert
|
||||
expect(onData).toHaveBeenCalledWith('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
@ -179,6 +183,7 @@ describe('handleStream', () => {
|
||||
})
|
||||
|
||||
it('should handle malformed JSON gracefully', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
@ -201,15 +206,19 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// Act
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert - malformed JSON triggers the catch block which calls onData and returns
|
||||
expect(onData).toHaveBeenCalled()
|
||||
expect(onCompleted).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should dispatch reasoning_chunk events to onReasoning', async () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const onReasoning = vi.fn()
|
||||
@ -239,8 +248,10 @@ describe('handleStream', () => {
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
// onReasoning is the last positional handler; fill the unused intervening slots.
|
||||
const interveningNoops = Array.from({ length: 29 }, () => undefined)
|
||||
|
||||
// Act
|
||||
;(handleStream as (...args: unknown[]) => void)(
|
||||
mockResponse,
|
||||
onData,
|
||||
@ -249,136 +260,23 @@ describe('handleStream', () => {
|
||||
onReasoning,
|
||||
)
|
||||
|
||||
// Wait for the stream to be processed
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
|
||||
// Assert - the full event object is forwarded to onReasoning, answer stays untouched
|
||||
expect(onReasoning).toHaveBeenCalledWith(reasoningEvent)
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should complete with error when the stream reader rejects', async () => {
|
||||
const onData = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
|
||||
const mockReader = {
|
||||
read: vi.fn().mockRejectedValueOnce(new Error('stream lost')),
|
||||
}
|
||||
|
||||
const mockResponse = {
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
handleStream(mockResponse, onData, onCompleted)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onData).toHaveBeenCalledWith('', false, {
|
||||
conversationId: undefined,
|
||||
messageId: '',
|
||||
errorMessage: 'Error: stream lost',
|
||||
errorCode: 'stream_read_error',
|
||||
})
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith(true, 'Error: stream lost')
|
||||
})
|
||||
|
||||
it('should throw error when response is not ok', () => {
|
||||
// Arrange
|
||||
const onData = vi.fn()
|
||||
const mockResponse = {
|
||||
ok: false,
|
||||
} as unknown as Response
|
||||
|
||||
// Act & Assert
|
||||
expect(() => handleStream(mockResponse, onData)).toThrow('Network response was not ok')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('ssePost and sseGet', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should report fetch failures through onError without throwing from the catch handler', async () => {
|
||||
const onError = vi.fn()
|
||||
vi.spyOn(globalThis, 'fetch').mockRejectedValueOnce(new TypeError('Network failed'))
|
||||
|
||||
await ssePost('/chat-messages', {
|
||||
body: {
|
||||
query: 'hello',
|
||||
},
|
||||
}, {
|
||||
onError,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('TypeError: Network failed')
|
||||
})
|
||||
expect(toast.error).toHaveBeenCalledWith('TypeError: Network failed')
|
||||
})
|
||||
|
||||
it('should report token refresh failures through onError', async () => {
|
||||
const onError = vi.fn()
|
||||
refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('refresh failed'))
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
|
||||
await ssePost('/chat-messages', {
|
||||
body: {
|
||||
query: 'hello',
|
||||
},
|
||||
}, {
|
||||
onError,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('Error: refresh failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should report event stream token refresh failures through onError', async () => {
|
||||
const onError = vi.fn()
|
||||
refreshAccessTokenOrReLoginMock.mockRejectedValueOnce(new Error('resume refresh failed'))
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(new Response(null, { status: 401 }))
|
||||
|
||||
await sseGet('/workflow/workflow-run-1/events', {}, {
|
||||
onError,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('Error: resume refresh failed')
|
||||
})
|
||||
})
|
||||
|
||||
it('should report stream reader failures through onError and onCompleted', async () => {
|
||||
const onError = vi.fn()
|
||||
const onCompleted = vi.fn()
|
||||
const mockReader = {
|
||||
read: vi.fn().mockRejectedValueOnce(new Error('stream lost')),
|
||||
}
|
||||
const response = {
|
||||
status: 200,
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => mockReader,
|
||||
},
|
||||
} as unknown as Response
|
||||
|
||||
vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce(response)
|
||||
|
||||
await ssePost('/chat-messages', {
|
||||
body: {
|
||||
query: 'hello',
|
||||
},
|
||||
}, {
|
||||
onError,
|
||||
onCompleted,
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onError).toHaveBeenCalledWith('Error: stream lost', 'stream_read_error')
|
||||
})
|
||||
expect(onCompleted).toHaveBeenCalledWith(true, 'Error: stream lost')
|
||||
expect(toast.error).toHaveBeenCalledWith('Error: stream lost')
|
||||
})
|
||||
})
|
||||
|
||||
@ -236,16 +236,6 @@ export const handleStream = (
|
||||
let buffer = ''
|
||||
let bufferObj: Record<string, any>
|
||||
let isFirstMessage = true
|
||||
const completeWithError = (errorMessage: string, errorCode?: string) => {
|
||||
onData('', false, {
|
||||
conversationId: bufferObj?.conversation_id,
|
||||
messageId: bufferObj?.message_id ?? '',
|
||||
errorMessage,
|
||||
errorCode,
|
||||
})
|
||||
onCompleted?.(true, errorMessage)
|
||||
}
|
||||
|
||||
function read() {
|
||||
let hasError = false
|
||||
reader?.read().then((result: ReadableStreamReadResult<Uint8Array>) => {
|
||||
@ -409,8 +399,6 @@ export const handleStream = (
|
||||
}
|
||||
if (!hasError)
|
||||
read()
|
||||
}, (e: unknown) => {
|
||||
completeWithError(String(e), 'stream_read_error')
|
||||
})
|
||||
}
|
||||
read()
|
||||
@ -565,9 +553,7 @@ export const ssePost = async (
|
||||
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
|
||||
ssePost(url, fetchOptions, otherOptions)
|
||||
}).catch((err) => {
|
||||
const errorMessage = String(err)
|
||||
console.error(err)
|
||||
onError?.(errorMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -625,10 +611,9 @@ export const ssePost = async (
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = String(e)
|
||||
if (errorMessage !== 'AbortError: The user aborted a request.' && !errorMessage.includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().errorMessage.includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(String(e))
|
||||
onError?.(e)
|
||||
})
|
||||
}
|
||||
|
||||
@ -718,9 +703,7 @@ export const sseGet = async (
|
||||
refreshAccessTokenOrReLogin(TIME_OUT).then(() => {
|
||||
sseGet(url, fetchOptions, otherOptions)
|
||||
}).catch((err) => {
|
||||
const errorMessage = String(err)
|
||||
console.error(err)
|
||||
onError?.(errorMessage)
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -778,10 +761,9 @@ export const sseGet = async (
|
||||
)
|
||||
})
|
||||
.catch((e) => {
|
||||
const errorMessage = String(e)
|
||||
if (errorMessage !== 'AbortError: The user aborted a request.' && !errorMessage.includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(errorMessage)
|
||||
onError?.(errorMessage)
|
||||
if (e.toString() !== 'AbortError: The user aborted a request.' && !e.toString().includes('TypeError: Cannot assign to read only property'))
|
||||
toast.error(String(e))
|
||||
onError?.(e)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -519,7 +519,7 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
expect(queryClient.getQueryData(inviteOptionsQueryKey)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should invalidate roster list but keep invite options stable after saving an agent draft', async () => {
|
||||
it('should keep roster and invite option lists stable after saving an agent draft', async () => {
|
||||
const consoleQuery = await loadConsoleQuery()
|
||||
const queryClient = new QueryClient()
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
@ -543,7 +543,7 @@ describe('consoleQuery agent mutation defaults', () => {
|
||||
createMutationContext(queryClient),
|
||||
)
|
||||
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
expect(invalidateQueries).not.toHaveBeenCalledWith({
|
||||
|
||||
@ -520,12 +520,12 @@ export const consoleQuery: RouterUtils<typeof consoleClient> = createTanstackQue
|
||||
put: {
|
||||
mutationOptions: {
|
||||
onSuccess: (_composerState, variables, _onMutateResult, context) => {
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
if (variables.body.save_strategy !== 'save_as_new_version')
|
||||
return
|
||||
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.get.key(),
|
||||
})
|
||||
context.client.invalidateQueries({
|
||||
queryKey: consoleQuery.agent.inviteOptions.get.key(),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user