Compare commits

..

3 Commits

Author SHA1 Message Date
15225e5825 build(deps): bump the github-actions-dependencies group across 1 directory with 6 updates
Bumps the github-actions-dependencies group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.3` | `7.0.0` |
| [actions/setup-python](https://github.com/actions/setup-python) | `6.2.0` | `6.3.0` |
| [actions/cache/restore](https://github.com/actions/cache) | `5.0.5` | `6.1.0` |
| [actions/cache/save](https://github.com/actions/cache) | `5.0.5` | `6.1.0` |
| [super-linter/super-linter/slim](https://github.com/super-linter/super-linter) | `8.6.0` | `8.7.0` |
| [anthropics/claude-code-action](https://github.com/anthropics/claude-code-action) | `1.0.151` | `1.0.159` |



Updates `actions/checkout` from 6.0.3 to 7.0.0
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](df4cb1c069...9c091bb21b)

Updates `actions/setup-python` from 6.2.0 to 6.3.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](a309ff8b42...ece7cb06ca)

Updates `actions/cache/restore` from 5.0.5 to 6.1.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](27d5ce7f10...55cc834586)

Updates `actions/cache/save` from 5.0.5 to 6.1.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](27d5ce7f10...55cc834586)

Updates `super-linter/super-linter/slim` from 8.6.0 to 8.7.0
- [Release notes](https://github.com/super-linter/super-linter/releases)
- [Changelog](https://github.com/super-linter/super-linter/blob/main/CHANGELOG.md)
- [Commits](9e863354e3...4ce20838b8)

Updates `anthropics/claude-code-action` from 1.0.151 to 1.0.159
- [Release notes](https://github.com/anthropics/claude-code-action/releases)
- [Commits](806af32823...a92e7c70a4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/setup-python
  dependency-version: 6.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: actions/cache/restore
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: actions/cache/save
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions-dependencies
- dependency-name: super-linter/super-linter/slim
  dependency-version: 8.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions-dependencies
- dependency-name: anthropics/claude-code-action
  dependency-version: 1.0.159
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-29 01:18:25 +00:00
yyh
9d3ac1b7b3 fix: simplify scroll area composition (#38113) 2026-06-29 01:05:12 +00:00
7a111c2226 refactor: shell provider protocol (#38077)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-28 08:23:13 +00:00
87 changed files with 2154 additions and 3980 deletions

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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()

View File

@ -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:

View File

@ -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=[

View 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",
]

View 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"]

View 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"]

View 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: ...

View 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",
]

View File

@ -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",
]

View File

@ -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,
),

View File

@ -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)

View File

@ -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",
)

View File

@ -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",

View File

@ -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:

View File

@ -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,

View File

@ -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

View File

@ -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}"

View File

@ -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>
)

View File

@ -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', () => {

View File

@ -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()

View File

@ -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([])
}
}
}

View File

@ -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,

View File

@ -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>

View File

@ -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}

View File

@ -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>

View File

@ -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

View File

@ -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}>

View File

@ -245,7 +245,7 @@ const ProviderList = ({
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>

View File

@ -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>

View File

@ -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 && (

View File

@ -164,7 +164,7 @@ const PluginsPanelResults = ({
)}
</ScrollAreaContent>
</ScrollAreaViewport>
<ScrollAreaScrollbar className="data-[orientation=vertical]:my-1 data-[orientation=vertical]:me-1">
<ScrollAreaScrollbar>
<ScrollAreaThumb />
</ScrollAreaScrollbar>
</ScrollAreaRoot>

View File

@ -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>

View File

@ -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))
})
})

View File

@ -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,
}
}

View File

@ -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)
})
})
})

View File

@ -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}
/>
)}
/>

View File

@ -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: {

View File

@ -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>
)
}

View File

@ -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)

View File

@ -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

View File

@ -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(() => {

View File

@ -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)
})
})

View File

@ -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()

View File

@ -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)}
/>
</>

View File

@ -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')
})
})

View File

@ -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' })

View File

@ -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}
>

View File

@ -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')

View File

@ -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 && (

View File

@ -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()

View File

@ -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()

View File

@ -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

View File

@ -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" />}

View File

@ -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,

View File

@ -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}
/>
)
}

View File

@ -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

View File

@ -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,

View File

@ -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,
}
}

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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}

View File

@ -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')
})
})

View File

@ -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)
})
}

View File

@ -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({

View File

@ -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(),
})