Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37b3c30e47 |
@@ -99,7 +99,6 @@ jobs:
|
||||
- '.nvmrc'
|
||||
- 'docker/docker-compose.middleware.yaml'
|
||||
- 'docker/envs/middleware.env.example'
|
||||
- '.github/workflows/main-ci.yml'
|
||||
- '.github/workflows/web-e2e.yml'
|
||||
- '.github/actions/setup-web/**'
|
||||
vdb:
|
||||
@@ -327,8 +326,6 @@ jobs:
|
||||
- check-changes
|
||||
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed == 'true'
|
||||
uses: ./.github/workflows/web-e2e.yml
|
||||
with:
|
||||
run-external-runtime: true
|
||||
secrets: inherit
|
||||
|
||||
web-e2e-skip:
|
||||
|
||||
@@ -96,15 +96,6 @@ jobs:
|
||||
vp run e2e:external:prepare
|
||||
vp run e2e:external
|
||||
|
||||
- name: Print E2E log tails
|
||||
if: ${{ failure() && inputs.run-external-runtime }}
|
||||
run: |
|
||||
while IFS= read -r log_file; do
|
||||
echo "::group::${log_file}"
|
||||
tail -n 200 "${log_file}"
|
||||
echo "::endgroup::"
|
||||
done < <(find e2e/.logs -type f | sort)
|
||||
|
||||
- name: Upload Cucumber report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
@@ -120,5 +111,5 @@ jobs:
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: e2e/.logs/**
|
||||
path: e2e/.logs
|
||||
retention-days: 7
|
||||
|
||||
@@ -103,17 +103,16 @@ class ApiTool(Tool):
|
||||
elif not isinstance(credentials["api_key_value"], str):
|
||||
raise ToolProviderCredentialValidationError("api_key_value must be a string")
|
||||
|
||||
api_key_value = credentials["api_key_value"]
|
||||
if "api_key_header_prefix" in credentials:
|
||||
api_key_header_prefix = credentials["api_key_header_prefix"]
|
||||
if api_key_header_prefix == "basic" and api_key_value:
|
||||
api_key_value = f"Basic {api_key_value}"
|
||||
elif api_key_header_prefix == "bearer" and api_key_value:
|
||||
api_key_value = f"Bearer {api_key_value}"
|
||||
if api_key_header_prefix == "basic" and credentials["api_key_value"]:
|
||||
credentials["api_key_value"] = f"Basic {credentials['api_key_value']}"
|
||||
elif api_key_header_prefix == "bearer" and credentials["api_key_value"]:
|
||||
credentials["api_key_value"] = f"Bearer {credentials['api_key_value']}"
|
||||
elif api_key_header_prefix == "custom":
|
||||
pass
|
||||
|
||||
headers[api_key_header] = api_key_value
|
||||
headers[api_key_header] = credentials["api_key_value"]
|
||||
|
||||
elif credentials["auth_type"] == "api_key_query":
|
||||
# For query parameter authentication, we don't add anything to headers
|
||||
|
||||
@@ -85,10 +85,6 @@ def test_assembling_request_auth_header_assembly():
|
||||
assert headers["Authorization"] == "Bearer abc"
|
||||
|
||||
tool.runtime.credentials = {"auth_type": "api_key_header", "api_key_header_prefix": "basic", "api_key_value": "abc"}
|
||||
headers = tool.assembling_request(parameters={})
|
||||
assert headers["Authorization"] == "Basic abc"
|
||||
assert tool.runtime.credentials["api_key_value"] == "abc"
|
||||
|
||||
headers = tool.assembling_request(parameters={})
|
||||
assert headers["Authorization"] == "Basic abc"
|
||||
|
||||
|
||||
@@ -60,7 +60,6 @@ class _HasErrorCode(Protocol):
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS = 10.0
|
||||
_INTERNAL_COMMAND_TIMEOUT_SECONDS = 90.0
|
||||
_WORKSPACE_ROOT = "~/workspace"
|
||||
_WORKSPACE_DIR_NAME = "workspace"
|
||||
_WORKSPACE_COLLISION_EXIT_CODE = 17
|
||||
@@ -560,7 +559,7 @@ class DifyShellLayer(PydanticAILayer[DifyShellLayerDeps, object, DifyShellLayerC
|
||||
script,
|
||||
cwd=cwd,
|
||||
env=self._build_shell_command_env(include_agent_stub_env=False),
|
||||
timeout=_INTERNAL_COMMAND_TIMEOUT_SECONDS,
|
||||
timeout=DEFAULT_TIMEOUT_SECONDS,
|
||||
max_output_bytes=_REMOTE_COMPLETE_OUTPUT_MAX_BYTES,
|
||||
)
|
||||
|
||||
|
||||
@@ -65,8 +65,6 @@ class ShellctlConfig:
|
||||
poll_interval_seconds: float = 0.05
|
||||
pipe_monitor_interval_seconds: float = 1.0
|
||||
pipe_ready_timeout_seconds: float = 10.0
|
||||
tmux_command_timeout_seconds: float = 15.0
|
||||
tmux_session_start_timeout_seconds: float = 90.0
|
||||
sqlite_busy_timeout_ms: int = DEFAULT_SQLITE_BUSY_TIMEOUT_MS
|
||||
sanitize_pty_command: tuple[str, ...] = ("shellctl-sanitize-pty",)
|
||||
runner_exit_command: tuple[str, ...] = ("shellctl-runner-exit",)
|
||||
|
||||
@@ -12,7 +12,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
import shutil
|
||||
import stat
|
||||
@@ -75,9 +74,6 @@ from shellctl.shared.schemas import (
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ShellctlService:
|
||||
"""SQLite-backed shellctl job service used by HTTP and local server entrypoints.
|
||||
|
||||
@@ -277,7 +273,6 @@ class ShellctlService:
|
||||
require_exit_code_null=True,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("shellctl job %s failed during startup: %s", job_id, exc, exc_info=True)
|
||||
await self._transition_status(
|
||||
job_id,
|
||||
allowed_from={
|
||||
|
||||
@@ -124,31 +124,20 @@ class TmuxController:
|
||||
str(cwd),
|
||||
]
|
||||
)
|
||||
session_name = job_session_name(job_id)
|
||||
try:
|
||||
result = await self._run_tmux(
|
||||
"-f",
|
||||
"/dev/null",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
session_name,
|
||||
"-x",
|
||||
str(terminal.cols),
|
||||
"-y",
|
||||
str(terminal.rows),
|
||||
runner_command,
|
||||
check=False,
|
||||
timeout_seconds=self._config.tmux_session_start_timeout_seconds,
|
||||
)
|
||||
except ShellctlServerError as exc:
|
||||
if exc.code == "tmux_timeout":
|
||||
session_probe = await self._session_exists_after_start_timeout(session_name)
|
||||
if session_probe is True:
|
||||
return
|
||||
message = f"{exc.message}; session_probe={self._format_optional_probe(session_probe)}"
|
||||
raise ShellctlServerError(exc.status_code, exc.code, message) from exc
|
||||
raise
|
||||
result = await self._run_tmux(
|
||||
"-f",
|
||||
"/dev/null",
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
job_session_name(job_id),
|
||||
"-x",
|
||||
str(terminal.cols),
|
||||
"-y",
|
||||
str(terminal.rows),
|
||||
runner_command,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
@@ -310,36 +299,19 @@ class TmuxController:
|
||||
check=False,
|
||||
)
|
||||
|
||||
async def _run_tmux(
|
||||
self,
|
||||
*args: str,
|
||||
check: bool = True,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
command = ["tmux", "-S", str(self._config.tmux_socket), *args]
|
||||
effective_timeout = (
|
||||
timeout_seconds if timeout_seconds is not None else self._config.tmux_command_timeout_seconds
|
||||
)
|
||||
async def _run_tmux(self, *args: str, check: bool = True) -> subprocess.CompletedProcess[bytes]:
|
||||
env = dict(os.environ)
|
||||
env.pop("TMUX", None)
|
||||
try:
|
||||
with anyio.fail_after(effective_timeout):
|
||||
result = await anyio.run_process(
|
||||
command,
|
||||
env=self._tmux_env(),
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
result = await anyio.run_process(
|
||||
["tmux", "-S", str(self._config.tmux_socket), *args],
|
||||
env=env,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise ShellctlServerError(500, "tmux_not_installed", "tmux executable was not found") from exc
|
||||
except TimeoutError as exc:
|
||||
message = f"tmux command timed out after {effective_timeout:.3f}s: {self._shell_join(command)}"
|
||||
raise ShellctlServerError(
|
||||
504,
|
||||
"tmux_timeout",
|
||||
message,
|
||||
) from exc
|
||||
if check and result.returncode != 0:
|
||||
raise ShellctlServerError(
|
||||
500,
|
||||
@@ -348,48 +320,10 @@ class TmuxController:
|
||||
)
|
||||
return result
|
||||
|
||||
async def _session_exists_after_start_timeout(self, session_name: str) -> bool | None:
|
||||
try:
|
||||
with anyio.fail_after(min(5.0, self._config.tmux_command_timeout_seconds)):
|
||||
result = await anyio.run_process(
|
||||
[
|
||||
"tmux",
|
||||
"-S",
|
||||
str(self._config.tmux_socket),
|
||||
"list-sessions",
|
||||
"-F",
|
||||
"#{session_name}",
|
||||
],
|
||||
env=self._tmux_env(),
|
||||
check=False,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
start_new_session=True,
|
||||
)
|
||||
except (FileNotFoundError, TimeoutError):
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
output = result.stdout.decode("utf-8", errors="replace")
|
||||
return session_name in {line.strip() for line in output.splitlines() if line.strip()}
|
||||
|
||||
@staticmethod
|
||||
def _format_optional_probe(probe: bool | None) -> str:
|
||||
if probe is None:
|
||||
return "unavailable"
|
||||
return "found" if probe else "missing"
|
||||
|
||||
@staticmethod
|
||||
def _shell_join(parts: tuple[str, ...] | list[str]) -> str:
|
||||
return " ".join(shlex.quote(part) for part in parts)
|
||||
|
||||
@staticmethod
|
||||
def _tmux_env() -> dict[str, str]:
|
||||
env = dict(os.environ)
|
||||
env.pop("TMUX", None)
|
||||
return env
|
||||
|
||||
|
||||
def _tmux_target_missing(stderr: str) -> bool:
|
||||
normalized = stderr.lower()
|
||||
|
||||
@@ -319,12 +319,6 @@ def test_shell_layer_create_allocates_workspace_and_bootstraps(monkeypatch: pyte
|
||||
assert layer.runtime_state.session_id == "abc12ff"
|
||||
assert layer.runtime_state.workspace_cwd == "~/workspace/abc12ff"
|
||||
assert [call.job_id for call in provider.resource.commands.delete_calls] == ["mkdir-job", "bootstrap-job"]
|
||||
assert [call.timeout for call in provider.resource.commands.run_calls] == pytest.approx(
|
||||
[
|
||||
shell_layer_module._INTERNAL_COMMAND_TIMEOUT_SECONDS,
|
||||
shell_layer_module._INTERNAL_COMMAND_TIMEOUT_SECONDS,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_shell_layer_uses_agent_specific_home_and_workspace_cwd(
|
||||
|
||||
@@ -1583,136 +1583,10 @@ def test_shellctl_config_defaults_to_lightweight_sanitize_entrypoint(
|
||||
config = ShellctlConfig(state_dir=tmp_path / "state", runtime_dir=tmp_path / "run")
|
||||
|
||||
assert config.pipe_ready_timeout_seconds == 10.0
|
||||
assert config.tmux_command_timeout_seconds == 15.0
|
||||
assert config.tmux_session_start_timeout_seconds == 90.0
|
||||
assert config.sanitize_pty_command == ("shellctl-sanitize-pty",)
|
||||
assert config.runner_exit_command == ("shellctl-runner-exit",)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tmux_command_timeout_returns_structured_error(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def slow_run_process(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
del args, kwargs
|
||||
await anyio.sleep(1)
|
||||
return subprocess.CompletedProcess(["tmux"], 0, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(anyio, "run_process", slow_run_process)
|
||||
controller = TmuxController(
|
||||
ShellctlConfig(
|
||||
state_dir=tmp_path / "state",
|
||||
runtime_dir=tmp_path / "run",
|
||||
tmux_command_timeout_seconds=0.01,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ShellctlServerError) as exc_info:
|
||||
await controller.list_sessions()
|
||||
|
||||
assert exc_info.value.status_code == 504
|
||||
assert exc_info.value.code == "tmux_timeout"
|
||||
assert "tmux command timed out after 0.010s" in exc_info.value.message
|
||||
assert "list-sessions" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tmux_commands_do_not_inherit_process_stdin_or_session(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_kwargs: dict[str, object] = {}
|
||||
|
||||
async def fake_run_process(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
del args
|
||||
captured_kwargs.update(kwargs)
|
||||
return subprocess.CompletedProcess(["tmux"], 0, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(anyio, "run_process", fake_run_process)
|
||||
controller = TmuxController(
|
||||
ShellctlConfig(
|
||||
state_dir=tmp_path / "state",
|
||||
runtime_dir=tmp_path / "run",
|
||||
)
|
||||
)
|
||||
|
||||
await controller.start_server()
|
||||
|
||||
assert captured_kwargs["stdin"] == subprocess.DEVNULL
|
||||
assert captured_kwargs["start_new_session"] is True
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tmux_session_start_uses_dedicated_timeout(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
async def slow_run_process(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
del args, kwargs
|
||||
await anyio.sleep(1)
|
||||
return subprocess.CompletedProcess(["tmux"], 0, stdout=b"", stderr=b"")
|
||||
|
||||
monkeypatch.setattr(anyio, "run_process", slow_run_process)
|
||||
controller = TmuxController(
|
||||
ShellctlConfig(
|
||||
state_dir=tmp_path / "state",
|
||||
runtime_dir=tmp_path / "run",
|
||||
tmux_command_timeout_seconds=30.0,
|
||||
tmux_session_start_timeout_seconds=0.01,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(ShellctlServerError) as exc_info:
|
||||
await controller.create_job_session(
|
||||
job_id="job-timeout",
|
||||
job_dir=tmp_path / "job-timeout",
|
||||
cwd=tmp_path,
|
||||
terminal=TerminalSize(cols=120, rows=80),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 504
|
||||
assert exc_info.value.code == "tmux_timeout"
|
||||
assert "tmux command timed out after 0.010s" in exc_info.value.message
|
||||
assert "new-session" in exc_info.value.message
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_tmux_session_start_timeout_succeeds_when_session_was_created(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
session_name = job_session_name("job-timeout")
|
||||
|
||||
async def fake_run_process(
|
||||
command: list[str],
|
||||
**kwargs: object,
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
del kwargs
|
||||
if "new-session" in command:
|
||||
await anyio.sleep(1)
|
||||
return subprocess.CompletedProcess(command, 0, stdout=b"", stderr=b"")
|
||||
if "list-sessions" in command:
|
||||
return subprocess.CompletedProcess(command, 0, stdout=f"{session_name}\n".encode(), stderr=b"")
|
||||
return subprocess.CompletedProcess(command, 1, stdout=b"", stderr=b"unexpected command")
|
||||
|
||||
monkeypatch.setattr(anyio, "run_process", fake_run_process)
|
||||
controller = TmuxController(
|
||||
ShellctlConfig(
|
||||
state_dir=tmp_path / "state",
|
||||
runtime_dir=tmp_path / "run",
|
||||
tmux_session_start_timeout_seconds=0.01,
|
||||
)
|
||||
)
|
||||
|
||||
await controller.create_job_session(
|
||||
job_id="job-timeout",
|
||||
job_dir=tmp_path / "job-timeout",
|
||||
cwd=tmp_path,
|
||||
terminal=TerminalSize(cols=120, rows=80),
|
||||
)
|
||||
|
||||
|
||||
def test_pipe_command_finalizer_commits_runner_exit_after_drain(tmp_path: Path) -> None:
|
||||
controller = TmuxController(
|
||||
ShellctlConfig(
|
||||
|
||||
@@ -514,9 +514,6 @@ services:
|
||||
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
|
||||
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
volumes:
|
||||
- ./volumes/sandbox/dependencies:/dependencies
|
||||
- ./volumes/sandbox/conf:/conf
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
|
||||
networks:
|
||||
|
||||
@@ -119,9 +119,6 @@ services:
|
||||
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
|
||||
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
volumes:
|
||||
- ./volumes/sandbox/dependencies:/dependencies
|
||||
- ./volumes/sandbox/conf:/conf
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
|
||||
networks:
|
||||
|
||||
@@ -520,9 +520,6 @@ services:
|
||||
HTTPS_PROXY: ${SANDBOX_HTTPS_PROXY:-http://ssrf_proxy:3128}
|
||||
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
|
||||
PIP_MIRROR_URL: ${PIP_MIRROR_URL:-}
|
||||
volumes:
|
||||
- ./volumes/sandbox/dependencies:/dependencies
|
||||
- ./volumes/sandbox/conf:/conf
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8194/health"]
|
||||
networks:
|
||||
|
||||
@@ -5,6 +5,8 @@ app:
|
||||
max_workers: 4
|
||||
max_requests: 50
|
||||
worker_timeout: 5
|
||||
python_path: /opt/python/bin/python3
|
||||
nodejs_path: /usr/local/bin/node
|
||||
enable_network: True # please make sure there is no network risk in your environment
|
||||
allowed_syscalls: # please leave it empty if you have no idea how seccomp works
|
||||
proxy:
|
||||
|
||||
@@ -6,11 +6,11 @@ import type {
|
||||
ChatRequestPayloadWithUser,
|
||||
PostChatMessagesResponse,
|
||||
} from '@dify/contracts/api/service/types.gen'
|
||||
import type { APIResponse } from '@playwright/test'
|
||||
import { request } from '@playwright/test'
|
||||
import { createApiContext, expectApiResponseOK, setAppSiteEnabled } from '../../../support/api'
|
||||
import { getTestAgent } from './agent'
|
||||
|
||||
const SERVICE_API_STREAM_TIMEOUT_MS = 120_000
|
||||
|
||||
export type AgentServiceApiChatResult = {
|
||||
body: PostChatMessagesResponse | unknown
|
||||
ok: boolean
|
||||
@@ -22,13 +22,12 @@ type ServiceApiSseEvent = {
|
||||
event?: string
|
||||
}
|
||||
|
||||
async function parseServiceApiChatResponse(response: Response) {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
async function parseServiceApiChatResponse(response: APIResponse) {
|
||||
const contentType = response.headers()['content-type'] ?? ''
|
||||
const text = await response.text().catch(() => '')
|
||||
|
||||
if (contentType.includes('text/event-stream'))
|
||||
return parseServiceApiSseStream(response)
|
||||
|
||||
const text = await response.text().catch(() => '')
|
||||
return parseServiceApiSseText(text)
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
@@ -47,36 +46,6 @@ async function parseServiceApiChatResponse(response: Response) {
|
||||
}
|
||||
}
|
||||
|
||||
async function parseServiceApiSseStream(response: Response) {
|
||||
if (!response.body)
|
||||
return parseServiceApiSseText(await response.text().catch(() => ''))
|
||||
|
||||
const reader = response.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let raw = ''
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done)
|
||||
break
|
||||
|
||||
raw += decoder.decode(value, { stream: true })
|
||||
const parsed = parseServiceApiSseText(raw)
|
||||
if (parsed.events.some(isTerminalServiceApiEvent)) {
|
||||
await reader.cancel().catch(() => {})
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
reader.releaseLock()
|
||||
}
|
||||
|
||||
raw += decoder.decode()
|
||||
return parseServiceApiSseText(raw)
|
||||
}
|
||||
|
||||
function parseServiceApiSseText(text: string) {
|
||||
const events: ServiceApiSseEvent[] = []
|
||||
const answers: string[] = []
|
||||
@@ -126,24 +95,6 @@ function parseServiceApiSseText(text: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function isTerminalServiceApiEvent(event: ServiceApiSseEvent) {
|
||||
if (event.event === 'message_end' || event.event === 'workflow_finished' || event.event === 'error')
|
||||
return true
|
||||
|
||||
const data = event.data
|
||||
return Boolean(
|
||||
data
|
||||
&& typeof data === 'object'
|
||||
&& !Array.isArray(data)
|
||||
&& 'event' in data
|
||||
&& (
|
||||
data.event === 'message_end'
|
||||
|| data.event === 'workflow_finished'
|
||||
|| data.event === 'error'
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export async function setAgentSiteAccessAndGetURL(
|
||||
agentId: string,
|
||||
enabled: boolean,
|
||||
@@ -201,6 +152,7 @@ export async function sendAgentServiceApiChatMessage({
|
||||
query?: string
|
||||
serviceApiBaseURL: string
|
||||
}): Promise<AgentServiceApiChatResult> {
|
||||
const ctx = await request.newContext()
|
||||
const body = {
|
||||
inputs: {},
|
||||
query,
|
||||
@@ -208,28 +160,22 @@ export async function sendAgentServiceApiChatMessage({
|
||||
user: 'e2e-agent-access-point',
|
||||
} satisfies ChatRequestPayloadWithUser
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), SERVICE_API_STREAM_TIMEOUT_MS)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${serviceApiBaseURL.replace(/\/$/, '')}/chat-messages`, {
|
||||
body: JSON.stringify(body),
|
||||
const response = await ctx.post(`${serviceApiBaseURL.replace(/\/$/, '')}/chat-messages`, {
|
||||
data: body,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
const responseBody = await parseServiceApiChatResponse(response)
|
||||
|
||||
return {
|
||||
body: responseBody as PostChatMessagesResponse | unknown,
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
ok: response.ok(),
|
||||
status: response.status(),
|
||||
}
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeout)
|
||||
await ctx.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,8 +9,6 @@ import {
|
||||
import { agentBuilderExpectedTokens, agentBuilderFixedInputs } from '../../agent-v2/support/agent-builder-resources'
|
||||
import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers'
|
||||
|
||||
const SERVICE_API_RUNTIME_STEP_TIMEOUT_MS = 130_000
|
||||
|
||||
async function enableAgentApiAccessWithKey(world: DifyWorld) {
|
||||
const agentId = getCurrentAgentId(world)
|
||||
const apiAccess = await setAgentApiAccess(agentId, true)
|
||||
@@ -174,42 +172,34 @@ Then('the Agent v2 API Reference should open in a new tab', async function (this
|
||||
this.agentBuilder.accessPoint.apiReferencePage = undefined
|
||||
})
|
||||
|
||||
When(
|
||||
'I send the Agent v2 Backend service API minimal request',
|
||||
{ timeout: SERVICE_API_RUNTIME_STEP_TIMEOUT_MS },
|
||||
async function (this: DifyWorld) {
|
||||
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
|
||||
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
|
||||
if (!serviceApiBaseURL)
|
||||
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
|
||||
if (!apiKey)
|
||||
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
|
||||
When('I send the Agent v2 Backend service API minimal request', async function (this: DifyWorld) {
|
||||
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
|
||||
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
|
||||
if (!serviceApiBaseURL)
|
||||
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
|
||||
if (!apiKey)
|
||||
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
|
||||
|
||||
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
|
||||
apiKey,
|
||||
serviceApiBaseURL,
|
||||
})
|
||||
},
|
||||
)
|
||||
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
|
||||
apiKey,
|
||||
serviceApiBaseURL,
|
||||
})
|
||||
})
|
||||
|
||||
When(
|
||||
'I send the Agent v2 Backend service API knowledge request',
|
||||
{ timeout: SERVICE_API_RUNTIME_STEP_TIMEOUT_MS },
|
||||
async function (this: DifyWorld) {
|
||||
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
|
||||
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
|
||||
if (!serviceApiBaseURL)
|
||||
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
|
||||
if (!apiKey)
|
||||
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
|
||||
When('I send the Agent v2 Backend service API knowledge request', async function (this: DifyWorld) {
|
||||
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
|
||||
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
|
||||
if (!serviceApiBaseURL)
|
||||
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
|
||||
if (!apiKey)
|
||||
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
|
||||
|
||||
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
|
||||
apiKey,
|
||||
query: agentBuilderFixedInputs.knowledgeRuntimeQuery,
|
||||
serviceApiBaseURL,
|
||||
})
|
||||
},
|
||||
)
|
||||
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
|
||||
apiKey,
|
||||
query: agentBuilderFixedInputs.knowledgeRuntimeQuery,
|
||||
serviceApiBaseURL,
|
||||
})
|
||||
})
|
||||
|
||||
const stringifyServiceApiBody = (body: unknown) => {
|
||||
try {
|
||||
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
} from './configure-helpers'
|
||||
|
||||
const BUILD_DRAFT_RUNTIME_STEP_TIMEOUT_MS = 180_000
|
||||
const BUILD_DRAFT_CHAT_RESPONSE_TIMEOUT_MS = 150_000
|
||||
const BUILD_DRAFT_NOTE_SYNC_TIMEOUT_MS = 30_000
|
||||
const BUILD_NOTE_FILE_NAME = 'build_note.md'
|
||||
const BUILD_NOTE_MARKER = 'E2E_BUILD_DRAFT_PASS'
|
||||
@@ -162,7 +161,7 @@ When(
|
||||
const chatResponsePromise = page.waitForResponse(response => (
|
||||
response.request().method() === 'POST'
|
||||
&& new URL(response.url()).pathname.endsWith(`/console/api/agent/${agentId}/chat-messages`)
|
||||
), { timeout: BUILD_DRAFT_CHAT_RESPONSE_TIMEOUT_MS })
|
||||
))
|
||||
|
||||
await page.getByRole('button', { name: 'Start build' }).click()
|
||||
expect((await checkoutResponsePromise).ok()).toBe(true)
|
||||
|
||||
@@ -12,8 +12,6 @@ import { hasToolEntry } from '../../agent-v2/support/preflight/tools'
|
||||
import { getPreseededToolContract } from '../../agent-v2/support/tools'
|
||||
import { expectProviderToolActionVisible, getCurrentAgentId } from './configure-helpers'
|
||||
|
||||
const TOOL_RUNTIME_STEP_TIMEOUT_MS = 180_000
|
||||
|
||||
const getToolsSection = (world: DifyWorld) =>
|
||||
world.getPage().getByRole('region', { name: 'Tools' })
|
||||
|
||||
@@ -273,28 +271,24 @@ Then(
|
||||
},
|
||||
)
|
||||
|
||||
When(
|
||||
'I send the Agent v2 Backend service API JSON Replace request',
|
||||
{ timeout: TOOL_RUNTIME_STEP_TIMEOUT_MS },
|
||||
async function (this: DifyWorld) {
|
||||
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
|
||||
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
|
||||
if (!serviceApiBaseURL)
|
||||
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
|
||||
if (!apiKey)
|
||||
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
|
||||
When('I send the Agent v2 Backend service API JSON Replace request', async function (this: DifyWorld) {
|
||||
const serviceApiBaseURL = this.agentBuilder.accessPoint.serviceApiBaseURL
|
||||
const apiKey = this.agentBuilder.accessPoint.generatedApiKey
|
||||
if (!serviceApiBaseURL)
|
||||
throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.')
|
||||
if (!apiKey)
|
||||
throw new Error('No Agent v2 API key found. Create a Backend service API key first.')
|
||||
|
||||
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
|
||||
apiKey,
|
||||
query: [
|
||||
'Verify JSON Replace now.',
|
||||
`Replace ${agentBuilderExpectedTokens.jsonToolBefore} with ${agentBuilderExpectedTokens.jsonToolAfter}.`,
|
||||
`Return the JSON result and include ${agentBuilderExpectedTokens.jsonToolAfter}.`,
|
||||
].join(' '),
|
||||
serviceApiBaseURL,
|
||||
})
|
||||
},
|
||||
)
|
||||
this.agentBuilder.accessPoint.serviceApiResponse = await sendAgentServiceApiChatMessage({
|
||||
apiKey,
|
||||
query: [
|
||||
'Verify JSON Replace now.',
|
||||
`Replace ${agentBuilderExpectedTokens.jsonToolBefore} with ${agentBuilderExpectedTokens.jsonToolAfter}.`,
|
||||
`Return the JSON result and include ${agentBuilderExpectedTokens.jsonToolAfter}.`,
|
||||
].join(' '),
|
||||
serviceApiBaseURL,
|
||||
})
|
||||
})
|
||||
|
||||
Then(
|
||||
'the Agent v2 Backend service API response should include the JSON Replace E2E marker',
|
||||
|
||||
@@ -68,87 +68,6 @@ const readLogTail = async (logFilePath: string) => {
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
const getShellctlAuthHeaders = () => {
|
||||
const token = process.env.E2E_SHELLCTL_AUTH_TOKEN || process.env.DIFY_AGENT_SHELLCTL_AUTH_TOKEN
|
||||
|
||||
return token ? { Authorization: `Bearer ${token}` } : undefined
|
||||
}
|
||||
|
||||
type ShellctlJobResult = {
|
||||
done?: boolean
|
||||
exit_code?: number | null
|
||||
job_id?: string
|
||||
offset?: number
|
||||
output?: string
|
||||
status?: string
|
||||
}
|
||||
|
||||
const prewarmShellctlSandbox = async (shellctlURL: string) => {
|
||||
const marker = 'shellctl-e2e-ready'
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 210_000)
|
||||
const deadline = Date.now() + 180_000
|
||||
let output = ''
|
||||
let latestBody: ShellctlJobResult | undefined
|
||||
|
||||
try {
|
||||
const response = await fetch(`${shellctlURL}/v1/jobs/run`, {
|
||||
body: JSON.stringify({
|
||||
idle_flush_seconds: 0.2,
|
||||
output_limit: 4096,
|
||||
script: `printf '${marker}'`,
|
||||
timeout: 180,
|
||||
}),
|
||||
headers: {
|
||||
...getShellctlAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
latestBody = await response.json().catch(() => undefined) as ShellctlJobResult | undefined
|
||||
output += latestBody?.output ?? ''
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error(`Shellctl sandbox prewarm failed: ${response.status} ${JSON.stringify(latestBody)}`)
|
||||
|
||||
while (latestBody?.job_id && !latestBody.done && Date.now() < deadline) {
|
||||
const waitResponse = await fetch(`${shellctlURL}/v1/jobs/${latestBody.job_id}/wait`, {
|
||||
body: JSON.stringify({
|
||||
idle_flush_seconds: 0.2,
|
||||
offset: latestBody.offset ?? output.length,
|
||||
output_limit: 4096,
|
||||
timeout: Math.min(30, Math.max(1, Math.ceil((deadline - Date.now()) / 1000))),
|
||||
}),
|
||||
headers: {
|
||||
...getShellctlAuthHeaders(),
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
method: 'POST',
|
||||
signal: controller.signal,
|
||||
})
|
||||
latestBody = await waitResponse.json().catch(() => undefined) as ShellctlJobResult | undefined
|
||||
output += latestBody?.output ?? ''
|
||||
|
||||
if (!waitResponse.ok)
|
||||
throw new Error(`Shellctl sandbox prewarm wait failed: ${waitResponse.status} ${JSON.stringify(latestBody)}`)
|
||||
}
|
||||
|
||||
if (
|
||||
!latestBody?.done
|
||||
|| latestBody.exit_code !== 0
|
||||
|| !output.includes(marker)
|
||||
) {
|
||||
throw new Error(
|
||||
`Shellctl sandbox prewarm failed: ${JSON.stringify(latestBody)} output=${JSON.stringify(output)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
const waitForUnexpectedProcessExit = async (
|
||||
managedProcess: ManagedProcess,
|
||||
shouldIgnoreExit: () => boolean,
|
||||
@@ -277,20 +196,17 @@ const main = async () => {
|
||||
let waitingForShellctl = true
|
||||
try {
|
||||
const shellctlPort = process.env.E2E_SHELLCTL_PORT || '5004'
|
||||
const shellctlURL = `http://127.0.0.1:${shellctlPort}`
|
||||
await Promise.race([
|
||||
waitForUrl(`${shellctlURL}/openapi.json`, 180_000, 1_000),
|
||||
waitForUrl(`http://127.0.0.1:${shellctlPort}/openapi.json`, 180_000, 1_000),
|
||||
waitForUnexpectedProcessExit(shellctlProcess, () => !waitingForShellctl),
|
||||
])
|
||||
await prewarmShellctlSandbox(shellctlURL)
|
||||
}
|
||||
catch (error) {
|
||||
if (error instanceof Error && error.message.includes('exited before becoming ready'))
|
||||
throw error
|
||||
|
||||
const detail = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Shellctl sandbox did not become ready or prewarm successfully: ${detail}. See ${shellctlProcess.logFilePath}.`,
|
||||
`Shellctl sandbox did not become ready. See ${shellctlProcess.logFilePath}.`,
|
||||
)
|
||||
}
|
||||
finally {
|
||||
|
||||
-34
@@ -315,40 +315,6 @@ describe('useAgentConfigureSync', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should save the latest dirty draft when Configure unmounts before autosave runs', async () => {
|
||||
const { store, unmount } = renderUseAgentConfigureSync()
|
||||
|
||||
act(() => {
|
||||
store.set(agentComposerDraftAtom, {
|
||||
...defaultAgentSoulConfigFormState,
|
||||
prompt: 'Route leave prompt',
|
||||
})
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
unmount()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(composerPutMutationFn).toHaveBeenCalledTimes(1)
|
||||
expect(composerPutMutationFn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
params: {
|
||||
agent_id: 'agent-1',
|
||||
},
|
||||
body: expect.objectContaining({
|
||||
variant: 'agent_app',
|
||||
save_strategy: 'save_to_current_version',
|
||||
agent_soul: expect.objectContaining({
|
||||
prompt: expect.objectContaining({
|
||||
system_prompt: 'Route leave prompt',
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should include Agent Soul files when autosaving file changes', async () => {
|
||||
const { store } = renderUseAgentConfigureSync()
|
||||
|
||||
|
||||
@@ -208,6 +208,12 @@ export function useAgentConfigureSync({
|
||||
})
|
||||
}, [debouncedSaveDraft, getAgentSoulDraft, store])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
debouncedSaveDraft.flush?.()
|
||||
}
|
||||
}, [debouncedSaveDraft])
|
||||
|
||||
useEffect(() => {
|
||||
const saveDraftWhenPageHidden = () => {
|
||||
if (document.visibilityState === 'hidden')
|
||||
@@ -226,12 +232,6 @@ export function useAgentConfigureSync({
|
||||
}
|
||||
}, [saveDirtyDraftOnPageClose])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
saveDirtyDraftOnPageClose()
|
||||
}
|
||||
}, [saveDirtyDraftOnPageClose])
|
||||
|
||||
const publishDraft = useCallback(async () => {
|
||||
if (publishInFlightRef.current)
|
||||
return
|
||||
|
||||
Reference in New Issue
Block a user