mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-05 17:17:14 +00:00
Compare commits
11 Commits
v4.10.9
...
deploy/prod
| Author | SHA1 | Date | |
|---|---|---|---|
| b44b8f474d | |||
| ab52684a01 | |||
| d50957fc4f | |||
| c8d8b1aac4 | |||
| 018dd7a363 | |||
| 7b7d3f04e8 | |||
| 601c6975ea | |||
| 5ca30133a3 | |||
| 5c49cb60e3 | |||
| 8cf0015502 | |||
| bf8d418ad4 |
@@ -1349,7 +1349,8 @@
|
|||||||
"local-agent",
|
"local-agent",
|
||||||
"tools",
|
"tools",
|
||||||
"e2b",
|
"e2b",
|
||||||
"nsjail"
|
"nsjail",
|
||||||
|
"host"
|
||||||
],
|
],
|
||||||
"automation": "",
|
"automation": "",
|
||||||
"setup_automation": [],
|
"setup_automation": [],
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ Key settings:
|
|||||||
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
| `api.global_api_key` | **Global API key** for the HTTP API + MCP server. Non-empty = accepted with no login/DB record; no `lbk_` prefix required. Empty = disabled. Plaintext — trusted/internal only, serve over HTTPS. |
|
||||||
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
| `plugin.runtime_ws_url` | Standalone plugin runtime WS URL (e.g. `ws://langbot_plugin_runtime:5400/control/ws`) |
|
||||||
| `box.enabled` | Master switch for the Box sandbox runtime |
|
| `box.enabled` | Master switch for the Box sandbox runtime |
|
||||||
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b`; env override `BOX__BACKEND` |
|
| `box.backend` | `local` (Docker/nsjail autopick) / `docker` / `nsjail` / `e2b` / explicit unsafe `host`; env override `BOX__BACKEND` |
|
||||||
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
| `box.runtime.endpoint` | External Box runtime URL (e.g. `ws://127.0.0.1:5410`); empty = local auto-managed |
|
||||||
|
|
||||||
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
||||||
@@ -75,6 +75,10 @@ Many keys have `ENV__SUBKEY` overrides (e.g. `BOX__BACKEND`, `BOX__ENABLED`).
|
|||||||
with `--standalone-runtime`.
|
with `--standalone-runtime`.
|
||||||
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
- Box has a parallel `--standalone-box` flag; the Docker box host is
|
||||||
`langbot_box:5410`.
|
`langbot_box:5410`.
|
||||||
|
- `box.backend: host` runs commands directly as the Box Runtime system user.
|
||||||
|
It is never auto-selected, provides no sandbox isolation, and is only for
|
||||||
|
trusted local development. A WebSocket-controlled host backend requires
|
||||||
|
`LANGBOT_BOX_CONTROL_TOKEN`; local stdio control is allowed.
|
||||||
|
|
||||||
## Global API key — enabling for agents/automation
|
## Global API key — enabling for agents/automation
|
||||||
|
|
||||||
@@ -93,5 +97,7 @@ login session. See `langbot-mcp-ops` for using it, and `docs/API_KEY_AUTH.md`.
|
|||||||
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
- "No supported sandbox backend (Docker / nsjail / E2B)" with Docker running
|
||||||
usually means the user isn't in the `docker` group →
|
usually means the user isn't in the `docker` group →
|
||||||
`sudo usermod -aG docker <user>` and restart in a new shell.
|
`sudo usermod -aG docker <user>` and restart in a new shell.
|
||||||
|
- Do not use `box.backend: host` as a production fallback. It cannot enforce
|
||||||
|
image, filesystem, network, PID, CPU, memory, or storage isolation.
|
||||||
- Box root host/container path mismatch breaks sandbox container creation.
|
- Box root host/container path mismatch breaks sandbox container creation.
|
||||||
- Don't commit a non-empty `api.global_api_key` to version control.
|
- Don't commit a non-empty `api.global_api_key` to version control.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ tags:
|
|||||||
- tools
|
- tools
|
||||||
- e2b
|
- e2b
|
||||||
- nsjail
|
- nsjail
|
||||||
|
- host
|
||||||
skills:
|
skills:
|
||||||
- langbot-env-setup
|
- langbot-env-setup
|
||||||
- langbot-testing
|
- langbot-testing
|
||||||
@@ -23,7 +24,7 @@ env:
|
|||||||
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
|
||||||
preconditions:
|
preconditions:
|
||||||
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
- "LANGBOT_LOCAL_AGENT_PIPELINE_URL or LANGBOT_LOCAL_AGENT_PIPELINE_NAME points to the local-agent pipeline under test."
|
||||||
- "LangBot is started with the sandbox backend intended for this run, such as e2b or nsjail."
|
- "LangBot is started with the Box backend intended for this run, such as e2b, nsjail, or explicit host development mode."
|
||||||
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
- "The selected model route supports tool/function calling strongly enough to invoke sandbox tools."
|
||||||
steps:
|
steps:
|
||||||
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
- "Start LangBot with the target sandbox backend and confirm the Box status UI or LANGBOT_BACKEND_URL /api/v1/box/status reports the expected backend."
|
||||||
@@ -33,7 +34,7 @@ steps:
|
|||||||
checks:
|
checks:
|
||||||
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
- "UI: Debug Chat final assistant response contains E2E_OK:<skill-name>."
|
||||||
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
- "Logs: The model called exec, register_skill, activate, then exec again from the activated skill path."
|
||||||
- "Logs: The selected backend name is the expected one, such as e2b or nsjail."
|
- "Logs: The selected backend name is the expected one, such as e2b, nsjail, or host."
|
||||||
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
- "Skill store: The registered package and activated writeback match references/sandbox-skill-authoring.md."
|
||||||
- "Box status: recent_error_count is 0 after the run."
|
- "Box status: recent_error_count is 0 after the run."
|
||||||
evidence_required:
|
evidence_required:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
Verify that Local Agent can use sandbox tools to create, register, activate, and use a LangBot skill package through the same path a user would exercise in Debug Chat.
|
||||||
|
|
||||||
This flow applies to Docker, nsjail, and E2B backends. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
This flow applies to Docker, nsjail, E2B, and the explicit host development backend. Host runs commands directly as the Box Runtime user and must never be treated as sandbox-isolation coverage. API calls are useful diagnostics, but the primary pass/fail signal is the model-driven Debug Chat tool sequence.
|
||||||
|
|
||||||
## Preconditions
|
## Preconditions
|
||||||
|
|
||||||
@@ -13,6 +13,7 @@ This flow applies to Docker, nsjail, and E2B backends. API calls are useful diag
|
|||||||
- `BOX_BACKEND=e2b` when validating E2B.
|
- `BOX_BACKEND=e2b` when validating E2B.
|
||||||
- `BOX_BACKEND=nsjail` when validating nsjail.
|
- `BOX_BACKEND=nsjail` when validating nsjail.
|
||||||
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
- `BOX_BACKEND=local` or `docker` when validating local container fallback.
|
||||||
|
- `BOX_BACKEND=host` only when validating explicit, trusted local direct execution.
|
||||||
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
3. Confirm `/api/v1/box/status` reports `available: true` and the expected backend name.
|
||||||
4. Confirm Debug Chat uses a model with function-calling ability.
|
4. Confirm Debug Chat uses a model with function-calling ability.
|
||||||
5. Confirm backend logs say native sandbox tools are available.
|
5. Confirm backend logs say native sandbox tools are available.
|
||||||
@@ -71,7 +72,7 @@ Backend logs should show:
|
|||||||
- `register_skill`
|
- `register_skill`
|
||||||
- `activate`
|
- `activate`
|
||||||
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
- a second `exec` whose workdir is `/workspace/.skills/<skill-name>`
|
||||||
- `backend=e2b`, `backend=nsjail`, or the expected local backend
|
- `backend=e2b`, `backend=nsjail`, `backend=host`, or the expected local backend
|
||||||
|
|
||||||
After the run, verify the skill store through the UI or API:
|
After the run, verify the skill store through the UI or API:
|
||||||
|
|
||||||
@@ -125,6 +126,8 @@ For E2B raw HTTP diagnostics, include a valid template id such as `base`; a miss
|
|||||||
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
- Session metadata should keep LangBot logical paths such as `/workspace`; storing provider-internal paths can make later requests look incompatible.
|
||||||
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
- nsjail versions differ. Some expose only `--disable_clone_new*` flags and use `--bindmount` instead of `--rw_bind`.
|
||||||
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
- On WSL, cgroup v2 may exist but not be writable. The backend should warn and fall back to rlimits rather than fail the sandbox.
|
||||||
|
- The host backend does not honor sandbox image, network, rootfs, process, or
|
||||||
|
resource isolation. Use a disposable workspace and low-privilege account.
|
||||||
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
- If `ALL_PROXY` uses a SOCKS URL and `socksio` is not installed, some Python HTTP clients can fail during startup. Prefer consistent HTTP proxy variables unless SOCKS support is installed.
|
||||||
|
|
||||||
## Related Troubleshooting
|
## Related Troubleshooting
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
|
|||||||
date: 2026-05-18
|
date: 2026-05-18
|
||||||
symptoms:
|
symptoms:
|
||||||
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
- "Backend logs show Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available."
|
||||||
- "The Box runtime later reports that E2B, nsjail, or Docker is configured."
|
- "The Box runtime later reports that E2B, nsjail, Docker, or explicit host mode is configured."
|
||||||
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
- "Debug Chat does not expose exec, register_skill, or activate as usable tools."
|
||||||
patterns:
|
patterns:
|
||||||
- "Native sandbox tools ... are NOT available"
|
- "Native sandbox tools ... are NOT available"
|
||||||
@@ -19,6 +19,7 @@ fix_steps:
|
|||||||
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
- "Ensure the Box runtime reselects a backend when get_backend_info is called and the cached backend is empty."
|
||||||
- "For E2B, verify the key without printing it and confirm any required template setting."
|
- "For E2B, verify the key without printing it and confirm any required template setting."
|
||||||
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
- "For nsjail, run nsjail --help and confirm the binary is on PATH for the LangBot process."
|
||||||
|
- "For trusted local development only, explicitly set box.backend=host; never use host as a production sandbox fallback."
|
||||||
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
verification: "Run sandbox-skill-authoring-e2e. Logs should show Native sandbox tools are available and /api/v1/box/status should report available=true with the expected backend."
|
||||||
related_cases:
|
related_cases:
|
||||||
- sandbox-skill-authoring-e2e
|
- sandbox-skill-authoring-e2e
|
||||||
|
|||||||
@@ -697,9 +697,10 @@ class DingTalkClient:
|
|||||||
if not await self.check_access_token():
|
if not await self.check_access_token():
|
||||||
await self.get_access_token()
|
await self.get_access_token()
|
||||||
|
|
||||||
cardData: dict = {'cardParamMap': _stringify_card_param_map(card_param_map)}
|
template_params = dict(card_param_map or {})
|
||||||
if card_data_config is not None:
|
if card_data_config is not None:
|
||||||
cardData['config'] = json.dumps(card_data_config)
|
template_params['config'] = card_data_config
|
||||||
|
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
|
||||||
|
|
||||||
body: dict = {
|
body: dict = {
|
||||||
'cardTemplateId': card_template_id,
|
'cardTemplateId': card_template_id,
|
||||||
|
|||||||
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
start_time_str = quart.request.args.get('startTime')
|
start_time_str = quart.request.args.get('startTime')
|
||||||
end_time_str = quart.request.args.get('endTime')
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
user_query = quart.request.args.get('userQuery')
|
||||||
is_active_str = quart.request.args.get('isActive')
|
is_active_str = quart.request.args.get('isActive')
|
||||||
limit = int(quart.request.args.get('limit', 100))
|
limit = int(quart.request.args.get('limit', 100))
|
||||||
offset = int(quart.request.args.get('offset', 0))
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
@@ -237,6 +238,7 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
start_time=start_time,
|
start_time=start_time,
|
||||||
end_time=end_time,
|
end_time=end_time,
|
||||||
|
user_query=user_query,
|
||||||
is_active=is_active,
|
is_active=is_active,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
|
||||||
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
|
||||||
"""Get detailed analysis for a specific session"""
|
"""Get detailed analysis for a specific session"""
|
||||||
analysis = await self.ap.monitoring_service.get_session_analysis(request_context, session_id)
|
start_time = parse_iso_datetime(quart.request.args.get('startTime'))
|
||||||
|
end_time = parse_iso_datetime(quart.request.args.get('endTime'))
|
||||||
|
analysis = await self.ap.monitoring_service.get_session_analysis(
|
||||||
|
request_context,
|
||||||
|
session_id,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
# Always return success with the analysis data
|
# Always return success with the analysis data
|
||||||
# The frontend will handle the 'found: false' case
|
# The frontend will handle the 'found: false' case
|
||||||
|
|||||||
@@ -186,6 +186,9 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
code = json_data.get('code')
|
code = json_data.get('code')
|
||||||
state = json_data.get('state')
|
state = json_data.get('state')
|
||||||
|
redirect_uri = json_data.get('redirect_uri') or (
|
||||||
|
quart.request.url_root.rstrip('/') + '/auth/space/callback'
|
||||||
|
)
|
||||||
launch_assertion = json_data.get('launch_assertion')
|
launch_assertion = json_data.get('launch_assertion')
|
||||||
workspace_uuid = json_data.get('workspace_uuid')
|
workspace_uuid = json_data.get('workspace_uuid')
|
||||||
|
|
||||||
@@ -199,8 +202,11 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.fail(1, 'Missing authorization code')
|
return self.fail(1, 'Missing authorization code')
|
||||||
if not state:
|
if not state:
|
||||||
return self.fail(1, 'Missing state parameter')
|
return self.fail(1, 'Missing state parameter')
|
||||||
|
if not str(code).startswith('v4_'):
|
||||||
|
return self.fail(1, 'Unsupported Space OAuth code contract')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=False)
|
||||||
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
consumed_state = await self.ap.user_service.consume_space_oauth_state_details(state, 'login')
|
||||||
# Exchange code for tokens
|
# Exchange code for tokens
|
||||||
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
launch_workspace_uuid = consumed_state.launch_workspace_uuid
|
||||||
@@ -218,24 +224,36 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
code,
|
code,
|
||||||
workspace_uuids,
|
workspace_uuids,
|
||||||
workspace_created_ats,
|
workspace_created_ats,
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
cloud_workspace_uuid = token_data.get('cloud_workspace_uuid')
|
||||||
|
|
||||||
if not access_token:
|
if not access_token:
|
||||||
return self.fail(1, 'Failed to get access token from Space')
|
return self.fail(1, 'Failed to get access token from Space')
|
||||||
|
|
||||||
# Authenticate and create/update local user
|
cloud_mode = getattr(getattr(self.ap, 'deployment', None), 'mode', 'oss') == 'cloud'
|
||||||
|
if cloud_mode and launch_workspace_uuid and launch_workspace_uuid != cloud_workspace_uuid:
|
||||||
|
return self.fail(1, 'Space OAuth Workspace binding mismatch')
|
||||||
|
target_workspace_uuid = launch_workspace_uuid or cloud_workspace_uuid
|
||||||
|
if cloud_mode:
|
||||||
|
if not target_workspace_uuid:
|
||||||
|
return self.fail(1, 'Space OAuth response is missing the Cloud Workspace binding')
|
||||||
|
await self.ap.directory_projection_service.reconcile_workspaces((target_workspace_uuid,))
|
||||||
|
|
||||||
|
# Authenticate only after the signed, exact Workspace delta has
|
||||||
|
# established the Account and membership runtime shadow rows.
|
||||||
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
jwt_token, user_obj = await self.ap.user_service.authenticate_space_user(
|
||||||
access_token, refresh_token, expires_in
|
access_token, refresh_token, expires_in
|
||||||
)
|
)
|
||||||
|
|
||||||
if launch_workspace_uuid:
|
if target_workspace_uuid:
|
||||||
try:
|
try:
|
||||||
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
|
||||||
user_obj.uuid,
|
user_obj.uuid,
|
||||||
launch_workspace_uuid,
|
target_workspace_uuid,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
self.ap.logger.warning('Rejected Space OAuth launch for unauthorized Workspace')
|
||||||
@@ -367,12 +385,17 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
code = json_data.get('code')
|
code = json_data.get('code')
|
||||||
state = json_data.get('state')
|
state = json_data.get('state')
|
||||||
|
redirect_uri = json_data.get('redirect_uri') or (
|
||||||
|
quart.request.url_root.rstrip('/') + '/auth/space/callback?mode=bind'
|
||||||
|
)
|
||||||
|
|
||||||
if not code:
|
if not code:
|
||||||
return self.http_status(400, -1, 'Missing authorization code')
|
return self.http_status(400, -1, 'Missing authorization code')
|
||||||
|
|
||||||
if not state:
|
if not state:
|
||||||
return self.http_status(400, -1, 'Missing state parameter')
|
return self.http_status(400, -1, 'Missing state parameter')
|
||||||
|
if not str(code).startswith('v4_'):
|
||||||
|
return self.http_status(400, -1, 'Unsupported Space OAuth code contract')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
user_obj = await self.ap.user_service.consume_space_oauth_state(state, 'bind')
|
||||||
@@ -385,7 +408,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
return self.http_status(400, -1, 'Only local accounts can bind to Space')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
updated_user = await self.ap.user_service.bind_space_account(user_obj.user, code)
|
redirect_uri = self._validate_space_redirect_uri(str(redirect_uri), bind=True)
|
||||||
|
updated_user = await self.ap.user_service.bind_space_account(
|
||||||
|
user_obj.user, code, redirect_uri=redirect_uri
|
||||||
|
)
|
||||||
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
jwt_token = await self.ap.user_service.generate_jwt_token(updated_user)
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
@@ -428,6 +454,10 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
projection_service = self.ap.directory_projection_service
|
||||||
|
if projection_service is None:
|
||||||
|
raise SpaceLaunchError('Cloud directory projection is unavailable')
|
||||||
|
await projection_service.reconcile_workspaces((launch['workspace_uuid'],))
|
||||||
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
|
||||||
if account is None:
|
if account is None:
|
||||||
raise SpaceLaunchError('Launch Account is not projected into Core')
|
raise SpaceLaunchError('Launch Account is not projected into Core')
|
||||||
|
|||||||
@@ -1257,6 +1257,7 @@ class MonitoringService:
|
|||||||
pipeline_ids: list[str] | None = None,
|
pipeline_ids: list[str] | None = None,
|
||||||
start_time: datetime.datetime | None = None,
|
start_time: datetime.datetime | None = None,
|
||||||
end_time: datetime.datetime | None = None,
|
end_time: datetime.datetime | None = None,
|
||||||
|
user_query: str | None = None,
|
||||||
is_active: bool | None = None,
|
is_active: bool | None = None,
|
||||||
limit: int = 100,
|
limit: int = 100,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
@@ -1274,6 +1275,14 @@ class MonitoringService:
|
|||||||
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
|
||||||
if end_time:
|
if end_time:
|
||||||
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
|
||||||
|
if user_query and user_query.strip():
|
||||||
|
user_pattern = f'%{user_query.strip()}%'
|
||||||
|
conditions.append(
|
||||||
|
sqlalchemy.or_(
|
||||||
|
persistence_monitoring.MonitoringSession.user_id.ilike(user_pattern),
|
||||||
|
persistence_monitoring.MonitoringSession.user_name.ilike(user_pattern),
|
||||||
|
)
|
||||||
|
)
|
||||||
if is_active is not None:
|
if is_active is not None:
|
||||||
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
|
||||||
|
|
||||||
@@ -1365,6 +1374,8 @@ class MonitoringService:
|
|||||||
self,
|
self,
|
||||||
context: TenantContext,
|
context: TenantContext,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Get bounded session details with full statistics computed in SQL."""
|
"""Get bounded session details with full statistics computed in SQL."""
|
||||||
workspace_uuid = require_workspace_uuid(context)
|
workspace_uuid = require_workspace_uuid(context)
|
||||||
@@ -1478,12 +1489,17 @@ class MonitoringService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
tool_stats = tool_stats_result.one()
|
tool_stats = tool_stats_result.one()
|
||||||
tool_query = (
|
tool_conditions = [
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
|
||||||
.where(
|
|
||||||
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
|
||||||
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
persistence_monitoring.MonitoringToolCall.session_id == session_id,
|
||||||
)
|
]
|
||||||
|
if start_time is not None:
|
||||||
|
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
||||||
|
if end_time is not None:
|
||||||
|
tool_conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
||||||
|
tool_query = (
|
||||||
|
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
||||||
|
.where(*tool_conditions)
|
||||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
||||||
.limit(detail_limit + 1)
|
.limit(detail_limit + 1)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ class SpaceService:
|
|||||||
|
|
||||||
space_config = self._get_space_config()
|
space_config = self._get_space_config()
|
||||||
authorize_url = space_config['oauth_authorize_url']
|
authorize_url = space_config['oauth_authorize_url']
|
||||||
params = {'redirect_uri': redirect_uri}
|
params = {'redirect_uri': redirect_uri, 'code_contract': 'redirect-v1'}
|
||||||
if state:
|
if state:
|
||||||
params['state'] = state
|
params['state'] = state
|
||||||
return f'{authorize_url}?{urlencode(params)}'
|
return f'{authorize_url}?{urlencode(params)}'
|
||||||
@@ -129,6 +129,8 @@ class SpaceService:
|
|||||||
code: str,
|
code: str,
|
||||||
workspace_uuids: list[str] | None = None,
|
workspace_uuids: list[str] | None = None,
|
||||||
workspace_created_ats: dict[str, int] | None = None,
|
workspace_created_ats: dict[str, int] | None = None,
|
||||||
|
*,
|
||||||
|
redirect_uri: str = '',
|
||||||
) -> typing.Dict:
|
) -> typing.Dict:
|
||||||
"""Exchange OAuth authorization code for tokens"""
|
"""Exchange OAuth authorization code for tokens"""
|
||||||
from langbot.pkg.utils import constants
|
from langbot.pkg.utils import constants
|
||||||
@@ -141,6 +143,7 @@ class SpaceService:
|
|||||||
f'{space_url}/api/v1/accounts/oauth/token',
|
f'{space_url}/api/v1/accounts/oauth/token',
|
||||||
json={
|
json={
|
||||||
'code': code,
|
'code': code,
|
||||||
|
'redirect_uri': redirect_uri,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
# Sending an explicit empty list tells new Space servers not to
|
# Sending an explicit empty list tells new Space servers not to
|
||||||
# synthesize a legacy instance-derived Workspace binding.
|
# synthesize a legacy instance-derived Workspace binding.
|
||||||
|
|||||||
@@ -774,7 +774,7 @@ class UserService:
|
|||||||
f'email:{normalized_email}',
|
f'email:{normalized_email}',
|
||||||
)
|
)
|
||||||
|
|
||||||
async def bind_space_account(self, user_email: str, code: str) -> user.User:
|
async def bind_space_account(self, user_email: str, code: str, *, redirect_uri: str = '') -> user.User:
|
||||||
"""Bind Space account to existing local account"""
|
"""Bind Space account to existing local account"""
|
||||||
local_account = await self.get_user_by_email(user_email)
|
local_account = await self.get_user_by_email(user_email)
|
||||||
if local_account is None:
|
if local_account is None:
|
||||||
@@ -794,12 +794,13 @@ class UserService:
|
|||||||
code,
|
code,
|
||||||
[binding.workspace_uuid],
|
[binding.workspace_uuid],
|
||||||
{binding.workspace_uuid: created_ts},
|
{binding.workspace_uuid: created_ts},
|
||||||
|
redirect_uri=redirect_uri,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# Compatibility for early/bootstrap call sites that have not wired
|
# Compatibility for early/bootstrap call sites that have not wired
|
||||||
# WorkspaceService yet; old Space servers still derive the legacy
|
# WorkspaceService yet; old Space servers still derive the legacy
|
||||||
# Workspace identity from instance_id when the field is omitted.
|
# Workspace identity from instance_id when the field is omitted.
|
||||||
token_data = await self.ap.space_service.exchange_oauth_code(code)
|
token_data = await self.ap.space_service.exchange_oauth_code(code, redirect_uri=redirect_uri)
|
||||||
access_token = token_data.get('access_token')
|
access_token = token_data.get('access_token')
|
||||||
refresh_token = token_data.get('refresh_token')
|
refresh_token = token_data.get('refresh_token')
|
||||||
expires_in = token_data.get('expires_in', 0)
|
expires_in = token_data.get('expires_in', 0)
|
||||||
|
|||||||
@@ -455,7 +455,9 @@ class BoxService:
|
|||||||
|
|
||||||
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
if self._cloud_managed:
|
if self._cloud_managed:
|
||||||
if self._admission is None:
|
if self._admission is None:
|
||||||
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
|
||||||
@@ -565,7 +567,9 @@ class BoxService:
|
|||||||
skip_host_mount_validation: bool = False,
|
skip_host_mount_validation: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
if not self._available:
|
if not self._available:
|
||||||
raise BoxError('Box runtime is not available. Install and start Docker to use sandbox features.')
|
raise BoxError(
|
||||||
|
'Box runtime is not available. Configure an available Box backend before using Box features.'
|
||||||
|
)
|
||||||
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
execution_context = await self._validated_execution_context(self._query_execution_context(query))
|
||||||
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
|
||||||
await self._require_validated_workspace_sandbox(execution_context)
|
await self._require_validated_workspace_sandbox(execution_context)
|
||||||
@@ -2142,5 +2146,8 @@ class BoxService:
|
|||||||
if backend_name:
|
if backend_name:
|
||||||
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
|
||||||
else:
|
else:
|
||||||
payload['connector_error'] = 'No supported sandbox backend (Docker / nsjail / E2B) is available'
|
payload['connector_error'] = (
|
||||||
|
'No supported sandbox backend (Docker / nsjail / E2B) is available. '
|
||||||
|
'Trusted local development may explicitly select the unsafe host backend.'
|
||||||
|
)
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
|
|||||||
# The database cursor remains the shared projection high-water mark,
|
# The database cursor remains the shared projection high-water mark,
|
||||||
# while this cursor tracks what this process has actually observed.
|
# while this cursor tracks what this process has actually observed.
|
||||||
self._consumer_cursor: int | None = None
|
self._consumer_cursor: int | None = None
|
||||||
|
self._sync_lock = asyncio.Lock()
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
"""Block Cloud startup until one full signed snapshot is committed."""
|
"""Block Cloud startup until one full signed snapshot is committed."""
|
||||||
|
|
||||||
|
async with self._sync_lock:
|
||||||
|
await self._refresh_snapshot()
|
||||||
|
|
||||||
|
async def refresh_snapshot(self) -> None:
|
||||||
|
"""Refresh from one full signed snapshot within the sync single-flight."""
|
||||||
|
|
||||||
|
async with self._sync_lock:
|
||||||
|
await self._refresh_snapshot()
|
||||||
|
|
||||||
|
async def _refresh_snapshot(self) -> None:
|
||||||
last_superseded: _DirectorySnapshotSuperseded | None = None
|
last_superseded: _DirectorySnapshotSuperseded | None = None
|
||||||
for _attempt in range(5):
|
for _attempt in range(5):
|
||||||
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
|
||||||
@@ -159,9 +170,84 @@ class DirectoryProjectionService:
|
|||||||
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
|
||||||
|
|
||||||
async def sync_once(self) -> None:
|
async def sync_once(self) -> None:
|
||||||
|
async with self._sync_lock:
|
||||||
|
await self._sync_once()
|
||||||
|
|
||||||
|
async def reconcile_workspaces(self, workspace_uuids: Iterable[str]) -> None:
|
||||||
|
"""Synchronously project an exact Workspace set without moving the event cursor."""
|
||||||
|
|
||||||
|
requested = tuple(sorted({str(value).strip() for value in workspace_uuids if str(value).strip()}))
|
||||||
|
if not requested:
|
||||||
|
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation requires a Workspace')
|
||||||
|
if len(requested) > self.event_limit:
|
||||||
|
raise DirectoryProjectionUnavailableError('Targeted directory reconciliation exceeds the batch limit')
|
||||||
|
async with self._sync_lock:
|
||||||
|
delta = await self.provider.fetch_workspaces(self.instance_uuid, requested)
|
||||||
|
await self._apply_targeted_delta(delta, requested)
|
||||||
|
|
||||||
|
async def _apply_targeted_delta(
|
||||||
|
self,
|
||||||
|
delta: DirectoryDelta,
|
||||||
|
requested_workspace_uuids: tuple[str, ...],
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(delta, DirectoryDelta):
|
||||||
|
raise DirectoryProjectionUnavailableError('Directory provider returned an invalid delta')
|
||||||
|
workspace_count, membership_count = self._validate_batch_capacity(
|
||||||
|
delta.workspaces,
|
||||||
|
full_snapshot=False,
|
||||||
|
)
|
||||||
|
delta = DirectoryDelta.model_validate(delta.model_dump())
|
||||||
|
if delta.instance_uuid != self.instance_uuid:
|
||||||
|
raise DirectoryProjectionUnavailableError('Directory delta targets another LangBot instance')
|
||||||
|
requested = set(requested_workspace_uuids)
|
||||||
|
if set(delta.requested_workspace_uuids) != requested:
|
||||||
|
raise DirectoryProjectionUnavailableError('Directory delta does not match the requested Workspaces')
|
||||||
|
if {workspace.uuid for workspace in delta.workspaces} != requested:
|
||||||
|
raise DirectoryProjectionUnavailableError('Directory delta omitted a requested Workspace')
|
||||||
|
|
||||||
|
directory_uow = getattr(self.ap.persistence_mgr, 'directory_projection_uow', None)
|
||||||
|
if not callable(directory_uow):
|
||||||
|
raise DirectoryProjectionUnavailableError('Directory projection persistence scope is unavailable')
|
||||||
|
|
||||||
|
async with directory_uow(self.instance_uuid) as uow:
|
||||||
|
session = uow.session
|
||||||
|
state = await session.scalar(
|
||||||
|
sqlalchemy.select(DirectoryProjectionState)
|
||||||
|
.where(DirectoryProjectionState.instance_uuid == self.instance_uuid)
|
||||||
|
.with_for_update()
|
||||||
|
)
|
||||||
|
if state is None:
|
||||||
|
raise DirectoryProjectionUnavailableError('Directory projection is not initialized')
|
||||||
|
snapshot = DirectorySnapshot(
|
||||||
|
instance_uuid=self.instance_uuid,
|
||||||
|
cursor=state.cursor,
|
||||||
|
generated_at=delta.generated_at,
|
||||||
|
workspaces=delta.workspaces,
|
||||||
|
)
|
||||||
|
accounts_by_uuid = await self._apply_accounts(session, snapshot, preserve_existing=True)
|
||||||
|
await self._apply_workspaces(session, snapshot, accounts_by_uuid=accounts_by_uuid)
|
||||||
|
active_workspace_count = await self._enforce_active_workspace_capacity(session)
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
await self._update_entitlement_workspace_activity(
|
||||||
|
snapshot.workspaces,
|
||||||
|
requested_workspace_uuids=requested,
|
||||||
|
)
|
||||||
|
self._publish_runtime_execution_projection(
|
||||||
|
snapshot.workspaces,
|
||||||
|
affected_workspace_uuids=requested,
|
||||||
|
)
|
||||||
|
self._request_model_catalog_sync()
|
||||||
|
self._record_batch_cardinality(
|
||||||
|
active_workspaces=active_workspace_count,
|
||||||
|
workspaces=workspace_count,
|
||||||
|
memberships=membership_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _sync_once(self) -> None:
|
||||||
cursor = self._consumer_cursor
|
cursor = self._consumer_cursor
|
||||||
if cursor is None:
|
if cursor is None:
|
||||||
await self.initialize()
|
await self._refresh_snapshot()
|
||||||
return
|
return
|
||||||
batch = await self.provider.fetch_events(
|
batch = await self.provider.fetch_events(
|
||||||
self.instance_uuid,
|
self.instance_uuid,
|
||||||
@@ -708,7 +794,13 @@ class DirectoryProjectionService:
|
|||||||
for row in inbox_rows:
|
for row in inbox_rows:
|
||||||
row.applied_at = now
|
row.applied_at = now
|
||||||
|
|
||||||
async def _apply_accounts(self, session: Any, snapshot: DirectorySnapshot) -> dict[str, User]:
|
async def _apply_accounts(
|
||||||
|
self,
|
||||||
|
session: Any,
|
||||||
|
snapshot: DirectorySnapshot,
|
||||||
|
*,
|
||||||
|
preserve_existing: bool = False,
|
||||||
|
) -> dict[str, User]:
|
||||||
selected: dict[str, DirectoryMember] = {}
|
selected: dict[str, DirectoryMember] = {}
|
||||||
emails: dict[str, str] = {}
|
emails: dict[str, str] = {}
|
||||||
for workspace in snapshot.workspaces:
|
for workspace in snapshot.workspaces:
|
||||||
@@ -773,6 +865,12 @@ class DirectoryProjectionService:
|
|||||||
continue
|
continue
|
||||||
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
if account.source != AccountSource.CLOUD_PROJECTION.value:
|
||||||
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
raise DirectoryProjectionUnavailableError('Directory account UUID collides with a local Core account')
|
||||||
|
if preserve_existing:
|
||||||
|
# A targeted Workspace fetch has no independently monotonic
|
||||||
|
# Account revision. It may create a missing runtime shadow, but
|
||||||
|
# ordered event/snapshot projection remains the only updater of
|
||||||
|
# existing Account identity and status fields.
|
||||||
|
continue
|
||||||
if account.projection_revision > snapshot.cursor:
|
if account.projection_revision > snapshot.cursor:
|
||||||
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
raise DirectoryProjectionUnavailableError('Directory account revision rolled back')
|
||||||
projected_account = self._account_projection(member)
|
projected_account = self._account_projection(member)
|
||||||
|
|||||||
@@ -160,6 +160,29 @@ def _lark_should_update_stream_element(
|
|||||||
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
return not resume_from and not form_data and (msg_seq % 8 == 0 or is_final)
|
||||||
|
|
||||||
|
|
||||||
|
def _lark_final_layout_texts(
|
||||||
|
*,
|
||||||
|
resume_from: bool,
|
||||||
|
text_message: str,
|
||||||
|
pre_pause_cached: str | None,
|
||||||
|
resume_cached: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Return (main_text, resume_placeholder_text) for the final card update.
|
||||||
|
|
||||||
|
Non-resume round: the full reply belongs in the main streaming element
|
||||||
|
only — also rendering the resume placeholder duplicates the reply, since
|
||||||
|
both hold the same accumulated text. Resume round (Dify HITL): keep the
|
||||||
|
pre-pause text in the main element and the resumed text in the
|
||||||
|
placeholder, as they are distinct segments.
|
||||||
|
"""
|
||||||
|
if resume_from:
|
||||||
|
# An empty pre-pause cache is valid (Dify paused before emitting any
|
||||||
|
# text); only a missing entry (None) falls back to the full text.
|
||||||
|
main_text = text_message if pre_pause_cached is None else pre_pause_cached
|
||||||
|
return main_text, resume_cached
|
||||||
|
return text_message, ''
|
||||||
|
|
||||||
|
|
||||||
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
def _lark_display_input_value(field: dict, value: typing.Any) -> str:
|
||||||
field_type = _dify_field_type(field)
|
field_type = _dify_field_type(field)
|
||||||
if field_type == 'file':
|
if field_type == 'file':
|
||||||
@@ -2358,16 +2381,21 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
self.card_form_input_defs[card_id] = _lark_form_input_defs(form_data)
|
||||||
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
self.card_form_inputs[card_id] = dict(form_data.get('inputs') or {})
|
||||||
else:
|
else:
|
||||||
# Normal finish: keep pre-pause + resume content visible,
|
# Normal finish: remove buttons/notice and finalize the card.
|
||||||
# remove buttons/notice, drop the resume placeholder.
|
main_text, resume_text = _lark_final_layout_texts(
|
||||||
|
resume_from=resume_from,
|
||||||
|
text_message=text_message,
|
||||||
|
pre_pause_cached=self.card_pre_pause_text.get(card_id),
|
||||||
|
resume_cached=resume_cached,
|
||||||
|
)
|
||||||
await self._update_card_layout(
|
await self._update_card_layout(
|
||||||
card_id=card_id,
|
card_id=card_id,
|
||||||
message_source=message_source,
|
message_source=message_source,
|
||||||
text_message=pre_pause,
|
text_message=main_text,
|
||||||
sequence=final_seq,
|
sequence=final_seq,
|
||||||
form_data=None,
|
form_data=None,
|
||||||
notice_text=selected_notice if resume_from else '',
|
notice_text=selected_notice if resume_from else '',
|
||||||
resume_placeholder_text=resume_cached,
|
resume_placeholder_text=resume_text,
|
||||||
)
|
)
|
||||||
self._drop_card_state(card_id)
|
self._drop_card_state(card_id)
|
||||||
self.card_id_dict.pop(message_id, None)
|
self.card_id_dict.pop(message_id, None)
|
||||||
|
|||||||
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
levels = ['provider_default', 'disabled', 'enabled']
|
levels = ['provider_default', 'disabled', 'enabled']
|
||||||
elif family == 'doubao':
|
elif family == 'doubao':
|
||||||
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
|
||||||
elif family == 'ollama':
|
elif family in ('ollama', 'ollama_chat'):
|
||||||
levels = ['provider_default']
|
levels = ['provider_default']
|
||||||
levels.append('disabled')
|
levels.append('disabled')
|
||||||
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
if normalized_name.startswith('gpt-oss') or '/gpt-oss' in normalized_name:
|
||||||
@@ -1345,7 +1345,14 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
extra_args: dict[str, typing.Any] = {},
|
extra_args: dict[str, typing.Any] = {},
|
||||||
) -> tuple[list[list[float]], dict]:
|
) -> tuple[list[list[float]], dict]:
|
||||||
"""Invoke embedding and return vectors with usage info."""
|
"""Invoke embedding and return vectors with usage info."""
|
||||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
# litellm's embedding routing has no "ollama_chat" branch (that provider
|
||||||
|
# exists only for /api/chat completions) — embeddings still go through
|
||||||
|
# the plain "ollama" provider. Requesters configured for ollama_chat
|
||||||
|
# (to get native tool-calling on the chat path) must fall back to
|
||||||
|
# "ollama" here specifically, or embedding calls raise "Unmapped LLM
|
||||||
|
# provider for this endpoint".
|
||||||
|
embedding_provider = 'ollama' if self._get_custom_llm_provider() == 'ollama_chat' else None
|
||||||
|
model_name = self._build_litellm_model_name(model.model_entity.name, embedding_provider)
|
||||||
api_key = model.provider.token_mgr.get_token()
|
api_key = model.provider.token_mgr.get_token()
|
||||||
|
|
||||||
args = {
|
args = {
|
||||||
@@ -1541,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
|||||||
event_hooks=httpclient.httpx_response_limit_hooks(),
|
event_hooks=httpclient.httpx_response_limit_hooks(),
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get(models_url, headers=headers)
|
response = await client.get(models_url, headers=headers)
|
||||||
|
if response.status_code == 404 and not base_url.rstrip('/').endswith('/v1'):
|
||||||
|
# Some OpenAI-compatible servers (notably a bare Ollama host,
|
||||||
|
# e.g. http://host:11434) expose the model list under /v1/models
|
||||||
|
# rather than /models. Providers whose configured base_url
|
||||||
|
# already ends in /v1 keep their original (working) URL.
|
||||||
|
response = await client.get(f'{base_url}/v1/models', headers=headers)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = await httpclient.parse_json_response(response)
|
payload = await httpclient.parse_json_response(response)
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ metadata:
|
|||||||
zh_Hans: Ollama
|
zh_Hans: Ollama
|
||||||
icon: ollama.svg
|
icon: ollama.svg
|
||||||
spec:
|
spec:
|
||||||
litellm_provider: ollama
|
litellm_provider: ollama_chat
|
||||||
config:
|
config:
|
||||||
- name: base_url
|
- name: base_url
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader):
|
|||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
|
||||||
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||||
|
'Trusted local development may explicitly select box.backend=host. '
|
||||||
'The LLM will not have access to code execution or file operation tools.'
|
'The LLM will not have access to code execution or file operation tools.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
|
|||||||
else:
|
else:
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
'Skill tools (activate/register_skill) are NOT available. '
|
'Skill tools (activate/register_skill) are NOT available. '
|
||||||
'No sandbox backend (Docker/nsjail/E2B) is ready.'
|
'No sandbox backend (Docker/nsjail/E2B) is ready. '
|
||||||
|
'Trusted local development may explicitly select box.backend=host.'
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _check_sandbox_available(self) -> bool:
|
async def _check_sandbox_available(self) -> bool:
|
||||||
|
|||||||
@@ -331,7 +331,10 @@ box:
|
|||||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||||
# be listed read-only and http/sse MCP servers continue to work.
|
# be listed read-only and http/sse MCP servers continue to work.
|
||||||
enabled: true
|
enabled: true
|
||||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
# 'host' runs commands directly as the Box Runtime user without sandbox
|
||||||
|
# isolation. It is never auto-selected and is only for trusted local
|
||||||
|
# development. Can be written via BOX__BACKEND.
|
||||||
|
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', 'e2b', or explicit unsafe 'host'.
|
||||||
runtime:
|
runtime:
|
||||||
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
|
||||||
# runtimes. To protect an exposed endpoint, set the same strong secret
|
# runtimes. To protect an exposed endpoint, set the same strong secret
|
||||||
|
|||||||
@@ -642,9 +642,10 @@
|
|||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim();
|
.trim();
|
||||||
if (
|
if (
|
||||||
prevContent === content ||
|
prevContent &&
|
||||||
|
(prevContent === content ||
|
||||||
prevContent.indexOf(content) >= 0 ||
|
prevContent.indexOf(content) >= 0 ||
|
||||||
content.indexOf(prevContent) >= 0
|
content.indexOf(prevContent) >= 0)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_sessions_forwards_user_search_and_page_window(self, quart_test_client, fake_monitoring_app):
|
||||||
|
fake_monitoring_app.monitoring_service.get_sessions.reset_mock()
|
||||||
|
|
||||||
|
response = await quart_test_client.get(
|
||||||
|
'/api/v1/monitoring/sessions?botId=bot-1&userQuery=alice&limit=20&offset=40',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
kwargs = fake_monitoring_app.monitoring_service.get_sessions.await_args.kwargs
|
||||||
|
assert kwargs['bot_ids'] == ['bot-1']
|
||||||
|
assert kwargs['user_query'] == 'alice'
|
||||||
|
assert kwargs['limit'] == 20
|
||||||
|
assert kwargs['offset'] == 40
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures('mock_circular_import_chain')
|
@pytest.mark.usefixtures('mock_circular_import_chain')
|
||||||
class TestMonitoringErrorsEndpoint:
|
class TestMonitoringErrorsEndpoint:
|
||||||
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
|
|||||||
"""Tests for detail endpoints."""
|
"""Tests for detail endpoints."""
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_session_analysis(self, quart_test_client):
|
async def test_get_session_analysis(self, quart_test_client, fake_monitoring_app):
|
||||||
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
"""GET /api/v1/monitoring/sessions/{id}/analysis."""
|
||||||
response = await quart_test_client.get(
|
response = await quart_test_client.get(
|
||||||
'/api/v1/monitoring/sessions/sess-1/analysis', headers={'Authorization': 'Bearer test_token'}
|
'/api/v1/monitoring/sessions/sess-1/analysis'
|
||||||
|
'?startTime=2026-08-31T16%3A00%3A00.000Z'
|
||||||
|
'&endTime=2026-09-01T15%3A59%3A59.999Z',
|
||||||
|
headers={'Authorization': 'Bearer test_token'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
kwargs = fake_monitoring_app.monitoring_service.get_session_analysis.await_args.kwargs
|
||||||
|
assert kwargs['start_time'].isoformat() == '2026-08-31T16:00:00'
|
||||||
|
assert kwargs['end_time'].isoformat() == '2026-09-01T15:59:59.999000'
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_get_message_details(self, quart_test_client):
|
async def test_get_message_details(self, quart_test_client):
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ async def space_oauth_api():
|
|||||||
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
|
||||||
)
|
)
|
||||||
application = Mock()
|
application = Mock()
|
||||||
application.deployment = SimpleNamespace(multi_workspace_enabled=False)
|
application.deployment = SimpleNamespace(multi_workspace_enabled=False, mode='oss')
|
||||||
|
application.directory_projection_service = None
|
||||||
application.persistence_mgr = None
|
application.persistence_mgr = None
|
||||||
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
application.user_service.get_authenticated_account = AsyncMock(return_value=account)
|
||||||
application.user_service.issue_space_oauth_state = AsyncMock(
|
application.user_service.issue_space_oauth_state = AsyncMock(
|
||||||
@@ -125,6 +126,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_entry_uses_normal_stateful_oauth(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
|
||||||
|
response = await client.get(
|
||||||
|
'/api/v1/user/space/authorize-url',
|
||||||
|
query_string={
|
||||||
|
'redirect_uri': 'http://localhost/auth/space/callback',
|
||||||
|
'cloud_entry': '1',
|
||||||
|
},
|
||||||
|
headers={'Origin': 'http://localhost'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
authorize_url = (await response.get_json())['data']['authorize_url']
|
||||||
|
assert authorize_url.startswith('https://space.example/authorize?state=')
|
||||||
|
application.user_service.issue_space_oauth_state.assert_awaited_once_with('login')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -249,10 +270,14 @@ async def test_server_side_webhook_origin_supports_bundled_ui(space_oauth_api):
|
|||||||
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
async def test_login_callback_requires_and_consumes_server_state(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
|
|
||||||
missing = await client.post('/api/v1/user/space/callback', json={'code': 'oauth-code'})
|
missing = await client.post('/api/v1/user/space/callback', json={'code': 'v4_oauth-code'})
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
json={
|
||||||
|
'code': 'v4_oauth-code',
|
||||||
|
'state': 'opaque-login-state',
|
||||||
|
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert (await missing.get_json())['code'] == 1
|
assert (await missing.get_json())['code'] == 1
|
||||||
@@ -260,12 +285,146 @@ async def test_login_callback_requires_and_consumes_server_state(space_oauth_api
|
|||||||
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
assert (await response.get_json())['data']['token'] == 'space-login-token'
|
||||||
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
application.user_service.consume_space_oauth_state_details.assert_awaited_once_with('opaque-login-state', 'login')
|
||||||
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
application.space_service.exchange_oauth_code.assert_awaited_once_with(
|
||||||
'oauth-code',
|
'v4_oauth-code',
|
||||||
[WORKSPACE_UUID],
|
[WORKSPACE_UUID],
|
||||||
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
{WORKSPACE_UUID: int(WORKSPACE_CREATED_AT.timestamp())},
|
||||||
|
redirect_uri='https://oss.example/auth/space/callback',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_login_callback_rejects_downgraded_legacy_code(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v2_legacy-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'code contract' in payload['msg']
|
||||||
|
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_reconciles_authorized_workspace_before_local_authentication(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
calls: list[str] = []
|
||||||
|
application.directory_projection_service = SimpleNamespace(
|
||||||
|
reconcile_workspaces=AsyncMock(side_effect=lambda _workspace_uuids: calls.append('reconcile'))
|
||||||
|
)
|
||||||
|
application.space_service.exchange_oauth_code.return_value = {
|
||||||
|
'access_token': 'space-access-token',
|
||||||
|
'refresh_token': 'space-refresh-token',
|
||||||
|
'expires_in': 3600,
|
||||||
|
'cloud_workspace_uuid': WORKSPACE_UUID,
|
||||||
|
}
|
||||||
|
|
||||||
|
authenticated_account = application.user_service.authenticate_space_user.return_value[1]
|
||||||
|
|
||||||
|
async def authenticate(*_args):
|
||||||
|
calls.append('authenticate')
|
||||||
|
return 'space-login-token', authenticated_account
|
||||||
|
|
||||||
|
application.user_service.authenticate_space_user.side_effect = authenticate
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||||
|
assert calls == ['reconcile', 'authenticate']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_fails_closed_without_workspace_binding(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Cloud Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_requires_code_binding_for_launch_state(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||||
|
launch_workspace_uuid=WORKSPACE_UUID
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cloud_login_callback_rejects_conflicting_state_and_code_workspace_bindings(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.deployment.mode = 'cloud'
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
application.user_service.consume_space_oauth_state_details.return_value = SimpleNamespace(
|
||||||
|
launch_workspace_uuid=WORKSPACE_UUID
|
||||||
|
)
|
||||||
|
application.space_service.exchange_oauth_code.return_value = {
|
||||||
|
'access_token': 'space-access-token',
|
||||||
|
'refresh_token': 'space-refresh-token',
|
||||||
|
'expires_in': 3600,
|
||||||
|
'cloud_workspace_uuid': 'workspace-from-another-flow',
|
||||||
|
}
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.get_json()
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert payload['code'] == 1
|
||||||
|
assert 'Workspace binding' in payload['msg']
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
application.user_service.authenticate_space_user.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_oss_login_callback_does_not_request_cloud_reconciliation(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
async def test_login_callback_launch_state_selects_asserted_workspace(space_oauth_api):
|
||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
@@ -276,7 +435,7 @@ async def test_login_callback_launch_state_selects_asserted_workspace(space_oaut
|
|||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-login-state'},
|
json={'code': 'v4_oauth-code', 'state': 'opaque-login-state'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -375,18 +534,22 @@ async def test_bind_callback_uses_opaque_state_and_never_treats_it_as_jwt(space_
|
|||||||
|
|
||||||
rejected = await client.post(
|
rejected = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/api/v1/user/bind-space',
|
||||||
json={'code': 'attacker-code', 'state': 'jwt.must-not-be-used'},
|
json={'code': 'v4_attacker-code', 'state': 'jwt.must-not-be-used'},
|
||||||
)
|
)
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/bind-space',
|
'/api/v1/user/bind-space',
|
||||||
json={'code': 'oauth-code', 'state': 'opaque-bind-state'},
|
json={'code': 'v4_oauth-code', 'state': 'opaque-bind-state'},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert rejected.status_code == 401
|
assert rejected.status_code == 401
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
assert (await response.get_json())['data']['token'] == 'rotated-account-token'
|
||||||
application.user_service.verify_jwt_token.assert_not_awaited()
|
application.user_service.verify_jwt_token.assert_not_awaited()
|
||||||
application.user_service.bind_space_account.assert_awaited_once_with('owner@example.com', 'oauth-code')
|
application.user_service.bind_space_account.assert_awaited_once_with(
|
||||||
|
'owner@example.com',
|
||||||
|
'v4_oauth-code',
|
||||||
|
redirect_uri='http://localhost/auth/space/callback?mode=bind',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -394,6 +557,7 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
application, client = space_oauth_api
|
application, client = space_oauth_api
|
||||||
application.user_service.consume_space_oauth_state.reset_mock()
|
application.user_service.consume_space_oauth_state.reset_mock()
|
||||||
application.space_service.exchange_oauth_code.reset_mock()
|
application.space_service.exchange_oauth_code.reset_mock()
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
response = await client.post(
|
response = await client.post(
|
||||||
'/api/v1/user/space/callback',
|
'/api/v1/user/space/callback',
|
||||||
@@ -414,3 +578,29 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
|
|||||||
)
|
)
|
||||||
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
application.user_service.consume_space_oauth_state.assert_not_awaited()
|
||||||
application.space_service.exchange_oauth_code.assert_not_awaited()
|
application.space_service.exchange_oauth_code.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_direct_launch_reconciles_exact_workspace_before_resolving_access(space_oauth_api):
|
||||||
|
application, client = space_oauth_api
|
||||||
|
projected_account = SimpleNamespace(
|
||||||
|
uuid='account-a',
|
||||||
|
user='owner@example.com',
|
||||||
|
account_type='space',
|
||||||
|
status='active',
|
||||||
|
)
|
||||||
|
application.user_service.get_user_by_uuid = AsyncMock(return_value=projected_account)
|
||||||
|
application.directory_projection_service = SimpleNamespace(reconcile_workspaces=AsyncMock())
|
||||||
|
|
||||||
|
response = await client.post(
|
||||||
|
'/api/v1/user/space/callback',
|
||||||
|
json={
|
||||||
|
'workspace_uuid': WORKSPACE_UUID,
|
||||||
|
'launch_assertion': 'signed-launch-token',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert (await response.get_json())['data']['workspace_uuid'] == WORKSPACE_UUID
|
||||||
|
application.directory_projection_service.reconcile_workspaces.assert_awaited_once_with((WORKSPACE_UUID,))
|
||||||
|
application.user_service.get_user_by_uuid.assert_awaited_once_with('account-a')
|
||||||
|
|||||||
@@ -138,6 +138,39 @@ async def test_same_session_and_resource_ids_do_not_collide(service):
|
|||||||
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
assert (await service.get_message_details(context_a, message_b))['found'] is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_session_search_matches_user_id_or_name_within_workspace(service):
|
||||||
|
context_a = _context(WORKSPACE_A)
|
||||||
|
context_b = _context(WORKSPACE_B)
|
||||||
|
fixtures = [
|
||||||
|
(context_a, 'session-id-match', 'customer-42', 'Alice'),
|
||||||
|
(context_a, 'session-name-match', 'customer-99', 'Bob Alice Cooper'),
|
||||||
|
(context_a, 'session-no-match', 'customer-7', 'Bob'),
|
||||||
|
(context_b, 'session-other-workspace', 'customer-42', 'Alice'),
|
||||||
|
]
|
||||||
|
for context, session_id, user_id, user_name in fixtures:
|
||||||
|
await service.record_session_start(
|
||||||
|
context,
|
||||||
|
session_id=session_id,
|
||||||
|
bot_id='same-bot',
|
||||||
|
bot_name='Same Bot',
|
||||||
|
pipeline_id='same-pipeline',
|
||||||
|
pipeline_name='Same Pipeline',
|
||||||
|
user_id=user_id,
|
||||||
|
user_name=user_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
by_id, id_total = await service.get_sessions(context_a, user_query='customer-42')
|
||||||
|
by_name, name_total = await service.get_sessions(context_a, user_query='alice')
|
||||||
|
|
||||||
|
assert id_total == 1
|
||||||
|
assert [session['session_id'] for session in by_id] == ['session-id-match']
|
||||||
|
assert name_total == 2
|
||||||
|
assert {session['session_id'] for session in by_name} == {
|
||||||
|
'session-id-match',
|
||||||
|
'session-name-match',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
async def test_tool_call_inherits_context_from_connection_message_row(service):
|
||||||
context = _context(WORKSPACE_A)
|
context = _context(WORKSPACE_A)
|
||||||
message_id = await _record_message(service, context, 'tool context')
|
message_id = await _record_message(service, context, 'tool context')
|
||||||
|
|||||||
@@ -95,7 +95,9 @@ class TestSpaceServiceGetOAuthAuthorizeUrl:
|
|||||||
result = service.get_oauth_authorize_url('http://localhost/callback')
|
result = service.get_oauth_authorize_url('http://localhost/callback')
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert parse_qs(urlsplit(result).query)['redirect_uri'] == ['http://localhost/callback']
|
query = parse_qs(urlsplit(result).query)
|
||||||
|
assert query['redirect_uri'] == ['http://localhost/callback']
|
||||||
|
assert query['code_contract'] == ['redirect-v1']
|
||||||
assert 'https://space.langbot.app/auth/authorize' in result
|
assert 'https://space.langbot.app/auth/authorize' in result
|
||||||
|
|
||||||
def test_get_oauth_authorize_url_with_state(self):
|
def test_get_oauth_authorize_url_with_state(self):
|
||||||
@@ -578,12 +580,14 @@ class TestSpaceServiceExchangeOAuthCode:
|
|||||||
'auth_code',
|
'auth_code',
|
||||||
['workspace-1'],
|
['workspace-1'],
|
||||||
{'workspace-1': 1_700_000_000},
|
{'workspace-1': 1_700_000_000},
|
||||||
|
redirect_uri='https://oss.example/auth/space/callback',
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify
|
# Verify
|
||||||
assert result['access_token'] == 'new_access_token'
|
assert result['access_token'] == 'new_access_token'
|
||||||
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
assert mock_session_obj.post.call_args.kwargs['json'] == {
|
||||||
'code': 'auth_code',
|
'code': 'auth_code',
|
||||||
|
'redirect_uri': 'https://oss.example/auth/space/callback',
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
'workspace_uuids': ['workspace-1'],
|
'workspace_uuids': ['workspace-1'],
|
||||||
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
'workspace_created_ats': {'workspace-1': 1_700_000_000},
|
||||||
@@ -846,10 +850,7 @@ class TestSpaceServiceGetModelSelection:
|
|||||||
if response_shape == 'models-envelope':
|
if response_shape == 'models-envelope':
|
||||||
data = {'models': models}
|
data = {'models': models}
|
||||||
elif response_shape == 'availability-wrapper':
|
elif response_shape == 'availability-wrapper':
|
||||||
data = [
|
data = [{'model': model, 'latency_ms': index + 10, 'http_code': 200} for index, model in enumerate(models)]
|
||||||
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
|
|
||||||
for index, model in enumerate(models)
|
|
||||||
]
|
|
||||||
else:
|
else:
|
||||||
data = models
|
data = models
|
||||||
payload = {'code': 0, 'data': data}
|
payload = {'code': 0, 'data': data}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
@@ -214,6 +215,88 @@ async def test_directory_delta_requests_model_catalog_sync_after_commit(projecti
|
|||||||
request_sync.assert_called_once_with()
|
request_sync.assert_called_once_with()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_targeted_reconciliation_projects_new_workspace_without_advancing_event_cursor(projection_context):
|
||||||
|
application, session_factory = projection_context
|
||||||
|
provider = _Provider(
|
||||||
|
[_snapshot(7, workspaces=[])],
|
||||||
|
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||||
|
)
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||||
|
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||||
|
membership = await session.scalar(
|
||||||
|
sqlalchemy.select(WorkspaceMembership).where(
|
||||||
|
WorkspaceMembership.workspace_uuid == WORKSPACE_UUID,
|
||||||
|
WorkspaceMembership.account_uuid == ACCOUNT_UUID,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||||
|
assert account is not None
|
||||||
|
assert workspace is not None and workspace.name == 'JIT Workspace'
|
||||||
|
assert membership is not None and membership.status == 'active'
|
||||||
|
assert state is not None and state.cursor == 7
|
||||||
|
assert provider.delta_calls == 1
|
||||||
|
assert provider.after_cursors == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_targeted_reconciliation_preserves_existing_account_until_ordered_event_projection(projection_context):
|
||||||
|
application, session_factory = projection_context
|
||||||
|
targeted_workspace = _workspace(revision=8, name='Renamed Workspace').model_copy(
|
||||||
|
update={
|
||||||
|
'members': [
|
||||||
|
_member(revision=8).model_copy(update={'display_name': 'Changed Account Name'})
|
||||||
|
]
|
||||||
|
}
|
||||||
|
)
|
||||||
|
provider = _Provider(
|
||||||
|
[_snapshot(7)],
|
||||||
|
deltas=[_delta(workspaces=[targeted_workspace])],
|
||||||
|
)
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
account = await session.scalar(sqlalchemy.select(User).where(User.uuid == ACCOUNT_UUID))
|
||||||
|
workspace = await session.get(Workspace, WORKSPACE_UUID)
|
||||||
|
state = await session.get(DirectoryProjectionState, INSTANCE_UUID)
|
||||||
|
assert account is not None and account.user == 'Workspace Owner'
|
||||||
|
assert account.projection_revision == 7
|
||||||
|
assert workspace is not None and workspace.name == 'Renamed Workspace'
|
||||||
|
assert state is not None and state.cursor == 7
|
||||||
|
|
||||||
|
|
||||||
|
async def test_targeted_reconciliation_only_updates_requested_workspace_side_effects(projection_context):
|
||||||
|
application, _session_factory = projection_context
|
||||||
|
provider = _Provider(
|
||||||
|
[_snapshot(7, workspaces=[])],
|
||||||
|
deltas=[_delta(workspaces=[_workspace(revision=8, name='JIT Workspace')])],
|
||||||
|
)
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
service._reconcile_entitlement_snapshot_set = AsyncMock()
|
||||||
|
service._update_entitlement_workspace_activity = AsyncMock()
|
||||||
|
service._publish_runtime_execution_projection = Mock()
|
||||||
|
|
||||||
|
await service.reconcile_workspaces((WORKSPACE_UUID,))
|
||||||
|
|
||||||
|
service._reconcile_entitlement_snapshot_set.assert_not_awaited()
|
||||||
|
service._update_entitlement_workspace_activity.assert_awaited_once()
|
||||||
|
assert service._update_entitlement_workspace_activity.await_args.kwargs == {
|
||||||
|
'requested_workspace_uuids': {WORKSPACE_UUID},
|
||||||
|
}
|
||||||
|
service._publish_runtime_execution_projection.assert_called_once()
|
||||||
|
assert service._publish_runtime_execution_projection.call_args.kwargs == {
|
||||||
|
'affected_workspace_uuids': {WORKSPACE_UUID},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
async def test_initial_snapshot_projects_core_owned_rows(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
reconcile_execution_projection = Mock()
|
reconcile_execution_projection = Mock()
|
||||||
@@ -735,6 +818,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
|
|||||||
assert second_provider.after_cursors == [1, 2]
|
assert second_provider.after_cursors == [1, 2]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_sync_once_calls_are_serialized_per_service(projection_context):
|
||||||
|
application, _session_factory = projection_context
|
||||||
|
|
||||||
|
class _ConcurrentProvider(_Provider):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__([_snapshot(1)])
|
||||||
|
self.first_fetch_started = asyncio.Event()
|
||||||
|
self.release_first_fetch = asyncio.Event()
|
||||||
|
self.active_fetches = 0
|
||||||
|
self.max_active_fetches = 0
|
||||||
|
|
||||||
|
async def fetch_events(
|
||||||
|
self,
|
||||||
|
instance_uuid: str,
|
||||||
|
after_cursor: int,
|
||||||
|
limit: int,
|
||||||
|
) -> DirectoryEventBatch:
|
||||||
|
assert instance_uuid == INSTANCE_UUID
|
||||||
|
assert limit == 100
|
||||||
|
self.after_cursors.append(after_cursor)
|
||||||
|
self.active_fetches += 1
|
||||||
|
self.max_active_fetches = max(self.max_active_fetches, self.active_fetches)
|
||||||
|
try:
|
||||||
|
if len(self.after_cursors) == 1:
|
||||||
|
self.first_fetch_started.set()
|
||||||
|
await self.release_first_fetch.wait()
|
||||||
|
cursor = after_cursor + 1
|
||||||
|
return DirectoryEventBatch(
|
||||||
|
instance_uuid=instance_uuid,
|
||||||
|
after_cursor=after_cursor,
|
||||||
|
cursor=cursor,
|
||||||
|
high_water_cursor=cursor,
|
||||||
|
events=(
|
||||||
|
DirectoryEvent(
|
||||||
|
cursor=cursor,
|
||||||
|
uuid=f'40000000-0000-4000-8000-{cursor:012d}',
|
||||||
|
aggregate_uuid=WORKSPACE_UUID,
|
||||||
|
event_type='entitlement.changed',
|
||||||
|
revision=cursor,
|
||||||
|
payload={
|
||||||
|
'workspace_uuid': WORKSPACE_UUID,
|
||||||
|
'entitlement_revision': cursor,
|
||||||
|
},
|
||||||
|
created_at=datetime.datetime(2026, 7, 24, 12, cursor, tzinfo=datetime.UTC),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self.active_fetches -= 1
|
||||||
|
|
||||||
|
provider = _ConcurrentProvider()
|
||||||
|
service = DirectoryProjectionService(application, provider, INSTANCE_UUID)
|
||||||
|
await service.initialize()
|
||||||
|
|
||||||
|
first = asyncio.create_task(service.sync_once())
|
||||||
|
await provider.first_fetch_started.wait()
|
||||||
|
second = asyncio.create_task(service.sync_once())
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
provider.release_first_fetch.set()
|
||||||
|
await asyncio.gather(first, second)
|
||||||
|
|
||||||
|
assert provider.max_active_fetches == 1
|
||||||
|
assert provider.after_cursors == [1, 2]
|
||||||
|
assert service._consumer_cursor == 3
|
||||||
|
|
||||||
|
|
||||||
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
async def test_snapshot_coverage_allows_lagging_replica_to_replay_receipts(projection_context):
|
||||||
application, session_factory = projection_context
|
application, session_factory = projection_context
|
||||||
event_two = DirectoryEvent(
|
event_two = DirectoryEvent(
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
"""Tests for DingTalk API payload helpers."""
|
"""Tests for DingTalk API payload helpers."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
from langbot.libs.dingtalk_api.api import _stringify_card_param_map
|
from langbot.libs.dingtalk_api.api import DingTalkClient, _stringify_card_param_map
|
||||||
|
from langbot.pkg.utils import httpclient
|
||||||
|
|
||||||
|
|
||||||
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
|
def test_dingtalk_card_param_map_stringifies_select_component_arrays():
|
||||||
@@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures():
|
|||||||
|
|
||||||
assert params['other'] == '["A"]'
|
assert params['other'] == '["A"]'
|
||||||
assert params['empty'] == ''
|
assert params['empty'] == ''
|
||||||
|
|
||||||
|
|
||||||
|
async def test_create_card_embeds_layout_config_as_template_parameter(monkeypatch):
|
||||||
|
response = type('Response', (), {'status_code': 200})()
|
||||||
|
post = AsyncMock(return_value=response)
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def client_context():
|
||||||
|
yield type('HttpClient', (), {'post': post})()
|
||||||
|
|
||||||
|
client = object.__new__(DingTalkClient)
|
||||||
|
client.access_token = 'access-token'
|
||||||
|
client.robot_code = 'robot-code'
|
||||||
|
client.key = 'client-id'
|
||||||
|
client.logger = None
|
||||||
|
client.check_access_token = AsyncMock(return_value=True)
|
||||||
|
client._http_client_context = client_context
|
||||||
|
monkeypatch.setattr(httpclient, 'response_text', AsyncMock(return_value='{}'))
|
||||||
|
|
||||||
|
original_params = {'content': 'hello'}
|
||||||
|
delivered = await client.create_and_deliver_card(
|
||||||
|
card_template_id='template-id',
|
||||||
|
out_track_id='track-id',
|
||||||
|
open_space_id='dtv1.card//IM_ROBOT.user-id',
|
||||||
|
is_group=False,
|
||||||
|
card_param_map=original_params,
|
||||||
|
card_data_config={'autoLayout': True},
|
||||||
|
)
|
||||||
|
|
||||||
|
request_body = post.await_args.kwargs['json']
|
||||||
|
assert delivered is True
|
||||||
|
assert request_body['cardData'] == {
|
||||||
|
'cardParamMap': {
|
||||||
|
'content': 'hello',
|
||||||
|
'config': '{"autoLayout": true}',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert original_params == {'content': 'hello'}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Tests for Lark adapter helper behavior."""
|
"""Tests for Lark adapter helper behavior."""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
from unittest.mock import MagicMock
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import (
|
|||||||
_lark_completed_input_lines,
|
_lark_completed_input_lines,
|
||||||
_lark_current_input_defs,
|
_lark_current_input_defs,
|
||||||
_lark_extract_action_form_inputs,
|
_lark_extract_action_form_inputs,
|
||||||
|
_lark_final_layout_texts,
|
||||||
_lark_should_update_stream_element,
|
_lark_should_update_stream_element,
|
||||||
_lark_visible_form_content,
|
_lark_visible_form_content,
|
||||||
)
|
)
|
||||||
@@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert lines == ['✅ xiala:B']
|
assert lines == ['✅ xiala:B']
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_final_layout_texts_normal_round_drops_resume_placeholder():
|
||||||
|
"""Non-resume final chunk: the reply must land in the main element only.
|
||||||
|
|
||||||
|
Regression: rendering the resume placeholder too duplicated the reply,
|
||||||
|
because the accumulated streaming text equals the final text on a normal
|
||||||
|
round (e.g. 'It is Sep 1, 2026.\nIt is Sep 1, 2026.' in the card).
|
||||||
|
"""
|
||||||
|
main_text, resume_text = _lark_final_layout_texts(
|
||||||
|
resume_from=False,
|
||||||
|
text_message='It is Sep 1, 2026, 15:09:15.',
|
||||||
|
pre_pause_cached=None,
|
||||||
|
resume_cached='It is Sep 1, 2026, 15:09:15.',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert main_text == 'It is Sep 1, 2026, 15:09:15.'
|
||||||
|
assert resume_text == ''
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_final_layout_texts_resume_round_keeps_both_segments():
|
||||||
|
"""Dify HITL resume final chunk: pre-pause text and resumed text differ,
|
||||||
|
both segments stay visible."""
|
||||||
|
main_text, resume_text = _lark_final_layout_texts(
|
||||||
|
resume_from=True,
|
||||||
|
text_message='resumed answer',
|
||||||
|
pre_pause_cached='partial answer before pause',
|
||||||
|
resume_cached='resumed answer',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert main_text == 'partial answer before pause'
|
||||||
|
assert resume_text == 'resumed answer'
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_final_layout_texts_resume_round_without_pre_pause_falls_back():
|
||||||
|
main_text, resume_text = _lark_final_layout_texts(
|
||||||
|
resume_from=True,
|
||||||
|
text_message='answer',
|
||||||
|
pre_pause_cached=None,
|
||||||
|
resume_cached='answer',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert main_text == 'answer'
|
||||||
|
assert resume_text == 'answer'
|
||||||
|
|
||||||
|
|
||||||
|
def test_lark_final_layout_texts_resume_round_empty_pre_pause_kept_empty():
|
||||||
|
"""Dify paused before emitting any text: the pre-pause cache is a valid
|
||||||
|
empty string and must NOT be treated as a cache miss.
|
||||||
|
|
||||||
|
Regression: `pre_pause_cached or text_message` fell back to the full
|
||||||
|
text, so the final card rendered ('resumed answer', 'resumed answer')
|
||||||
|
and duplicated the reply.
|
||||||
|
"""
|
||||||
|
main_text, resume_text = _lark_final_layout_texts(
|
||||||
|
resume_from=True,
|
||||||
|
text_message='resumed answer',
|
||||||
|
pre_pause_cached='',
|
||||||
|
resume_cached='resumed answer',
|
||||||
|
)
|
||||||
|
|
||||||
|
assert main_text == ''
|
||||||
|
assert resume_text == 'resumed answer'
|
||||||
|
|
||||||
|
|
||||||
|
def _build_resume_final_chunk_adapter(message_text: str):
|
||||||
|
"""Build a LarkAdapter whose card state mimics a Dify HITL round that
|
||||||
|
paused before emitting any text, then resumed and completed."""
|
||||||
|
adapter = LarkAdapter.model_construct(
|
||||||
|
api_client=MagicMock(),
|
||||||
|
message_converter=MagicMock(yiri2target=AsyncMock(return_value=([[{'tag': 'text', 'text': message_text}]], []))),
|
||||||
|
)
|
||||||
|
adapter.config = {'app_type': 'self'}
|
||||||
|
LarkAdapter.get_app_access_token = lambda self: None
|
||||||
|
LarkAdapter.get_tenant_access_token = lambda self, tenant_key: None
|
||||||
|
adapter.card_id_dict = {'msg-1': 'card-1'}
|
||||||
|
adapter.card_streaming_text = {'card-1': message_text}
|
||||||
|
adapter.card_pre_pause_text = {'card-1': ''}
|
||||||
|
adapter.card_resume_transitioned = {'card-1'}
|
||||||
|
adapter.card_sequence_dict = {}
|
||||||
|
adapter.card_last_accessed = {}
|
||||||
|
adapter.card_cleanup_at = 0.0
|
||||||
|
adapter.card_id_to_source_ids = {}
|
||||||
|
adapter.reply_message_card_ids = {}
|
||||||
|
adapter.card_form_content = {}
|
||||||
|
adapter.card_form_input_defs = {}
|
||||||
|
adapter.card_form_inputs = {}
|
||||||
|
adapter._update_card_layout = AsyncMock()
|
||||||
|
return adapter
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reply_message_chunk_resume_final_with_empty_pre_pause_keeps_main_empty():
|
||||||
|
"""End-to-end regression via reply_message_chunk: Dify paused before any
|
||||||
|
text, so the pre-pause cache is ''. The final card update must render the
|
||||||
|
resumed answer only once (empty main text + resume placeholder), not
|
||||||
|
twice as ('resumed answer', 'resumed answer')."""
|
||||||
|
adapter = _build_resume_final_chunk_adapter('resumed answer')
|
||||||
|
|
||||||
|
bot_message = MagicMock(
|
||||||
|
resp_message_id='msg-1',
|
||||||
|
msg_sequence=1,
|
||||||
|
spec=['resp_message_id', 'msg_sequence', '_resume_from_form'],
|
||||||
|
)
|
||||||
|
bot_message._resume_from_form = True
|
||||||
|
message_source = MagicMock(source_platform_object=None)
|
||||||
|
|
||||||
|
await adapter.reply_message_chunk(
|
||||||
|
message_source,
|
||||||
|
bot_message,
|
||||||
|
MagicMock(),
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter._update_card_layout.assert_awaited_once()
|
||||||
|
layout_kwargs = adapter._update_card_layout.await_args.kwargs
|
||||||
|
assert layout_kwargs['text_message'] == ''
|
||||||
|
assert layout_kwargs['resume_placeholder_text'] == 'resumed answer'
|
||||||
|
|||||||
@@ -43,17 +43,24 @@ const pendingSpaceOAuthLogins = new Map<
|
|||||||
function getOrCreateSpaceOAuthLoginPromise(
|
function getOrCreateSpaceOAuthLoginPromise(
|
||||||
authCode: string,
|
authCode: string,
|
||||||
state: string,
|
state: string,
|
||||||
|
redirectUri: string,
|
||||||
workspaceUuid?: string,
|
workspaceUuid?: string,
|
||||||
launchAssertion?: string,
|
launchAssertion?: string,
|
||||||
): Promise<SpaceOAuthLoginResult> {
|
): Promise<SpaceOAuthLoginResult> {
|
||||||
const requestKey = `${authCode}:${state}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
const requestKey = `${authCode}:${state}:${redirectUri}:${workspaceUuid ?? ''}:${launchAssertion ?? ''}`;
|
||||||
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
|
const pendingRequest = pendingSpaceOAuthLogins.get(requestKey);
|
||||||
if (pendingRequest) {
|
if (pendingRequest) {
|
||||||
return pendingRequest;
|
return pendingRequest;
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestPromise = httpClient
|
const requestPromise = httpClient
|
||||||
.exchangeSpaceOAuthCode(authCode, state, workspaceUuid, launchAssertion)
|
.exchangeSpaceOAuthCode(
|
||||||
|
authCode,
|
||||||
|
state,
|
||||||
|
redirectUri,
|
||||||
|
workspaceUuid,
|
||||||
|
launchAssertion,
|
||||||
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
pendingSpaceOAuthLogins.delete(requestKey);
|
pendingSpaceOAuthLogins.delete(requestKey);
|
||||||
});
|
});
|
||||||
@@ -95,6 +102,7 @@ function SpaceOAuthCallbackContent() {
|
|||||||
const response = await getOrCreateSpaceOAuthLoginPromise(
|
const response = await getOrCreateSpaceOAuthLoginPromise(
|
||||||
authCode,
|
authCode,
|
||||||
state,
|
state,
|
||||||
|
`${window.location.origin}/auth/space/callback`,
|
||||||
workspaceUuid,
|
workspaceUuid,
|
||||||
launchAssertion,
|
launchAssertion,
|
||||||
);
|
);
|
||||||
@@ -195,7 +203,11 @@ function SpaceOAuthCallbackContent() {
|
|||||||
async (authCode: string, state: string) => {
|
async (authCode: string, state: string) => {
|
||||||
setIsProcessing(true);
|
setIsProcessing(true);
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.bindSpaceAccount(authCode, state);
|
const response = await httpClient.bindSpaceAccount(
|
||||||
|
authCode,
|
||||||
|
state,
|
||||||
|
`${window.location.origin}/auth/space/callback?mode=bind`,
|
||||||
|
);
|
||||||
if (!isMountedRef.current) {
|
if (!isMountedRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Workflow,
|
Workflow,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
@@ -117,16 +118,43 @@ interface BotSessionMonitorProps {
|
|||||||
botId: string;
|
botId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SESSION_PAGE_SIZE = 20;
|
||||||
|
const MESSAGE_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
const localDateBoundaryToISOString = (
|
||||||
|
dateValue: string,
|
||||||
|
endOfDay: boolean,
|
||||||
|
): string => {
|
||||||
|
const [year, month, day] = dateValue.split('-').map(Number);
|
||||||
|
return new Date(
|
||||||
|
year,
|
||||||
|
month - 1,
|
||||||
|
day,
|
||||||
|
endOfDay ? 23 : 0,
|
||||||
|
endOfDay ? 59 : 0,
|
||||||
|
endOfDay ? 59 : 0,
|
||||||
|
endOfDay ? 999 : 0,
|
||||||
|
).toISOString();
|
||||||
|
};
|
||||||
|
|
||||||
const BotSessionMonitor = forwardRef<
|
const BotSessionMonitor = forwardRef<
|
||||||
BotSessionMonitorHandle,
|
BotSessionMonitorHandle,
|
||||||
BotSessionMonitorProps
|
BotSessionMonitorProps
|
||||||
>(function BotSessionMonitor({ botId }, ref) {
|
>(function BotSessionMonitor({ botId }, ref) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||||
|
const [sessionTotal, setSessionTotal] = useState(0);
|
||||||
|
const [sessionPage, setSessionPage] = useState(0);
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [userQuery, setUserQuery] = useState('');
|
||||||
|
const [appliedUserQuery, setAppliedUserQuery] = useState('');
|
||||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||||
|
const [messageTotal, setMessageTotal] = useState(0);
|
||||||
|
const [messagePage, setMessagePage] = useState(0);
|
||||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||||
@@ -138,6 +166,8 @@ const BotSessionMonitor = forwardRef<
|
|||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>({});
|
>({});
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const sessionRequestIdRef = useRef(0);
|
||||||
|
const messageRequestIdRef = useRef(0);
|
||||||
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
||||||
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
||||||
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
|
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
|
||||||
@@ -204,16 +234,33 @@ const BotSessionMonitor = forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
|
const requestId = ++sessionRequestIdRef.current;
|
||||||
setLoadingSessions(true);
|
setLoadingSessions(true);
|
||||||
try {
|
try {
|
||||||
const response = await httpClient.getBotSessions(botId);
|
const response = await httpClient.getBotSessions(botId, {
|
||||||
|
limit: SESSION_PAGE_SIZE,
|
||||||
|
offset: sessionPage * SESSION_PAGE_SIZE,
|
||||||
|
startTime: startDate
|
||||||
|
? localDateBoundaryToISOString(startDate, false)
|
||||||
|
: undefined,
|
||||||
|
endTime: endDate
|
||||||
|
? localDateBoundaryToISOString(endDate, true)
|
||||||
|
: undefined,
|
||||||
|
userQuery: appliedUserQuery || undefined,
|
||||||
|
});
|
||||||
|
if (requestId !== sessionRequestIdRef.current) return;
|
||||||
setSessions(response.sessions ?? []);
|
setSessions(response.sessions ?? []);
|
||||||
|
setSessionTotal(response.total ?? 0);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId === sessionRequestIdRef.current) {
|
||||||
console.error('Failed to load sessions:', error);
|
console.error('Failed to load sessions:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === sessionRequestIdRef.current) {
|
||||||
setLoadingSessions(false);
|
setLoadingSessions(false);
|
||||||
}
|
}
|
||||||
}, [botId]);
|
}
|
||||||
|
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
@@ -224,25 +271,39 @@ const BotSessionMonitor = forwardRef<
|
|||||||
);
|
);
|
||||||
|
|
||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (sessionId: string) => {
|
async (sessionId: string, page: number) => {
|
||||||
|
const requestId = ++messageRequestIdRef.current;
|
||||||
setLoadingMessages(true);
|
setLoadingMessages(true);
|
||||||
setExpandedToolCallIds({});
|
setExpandedToolCallIds({});
|
||||||
try {
|
try {
|
||||||
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
const messagesRes = await httpClient.getSessionMessages(
|
||||||
|
sessionId,
|
||||||
|
MESSAGE_PAGE_SIZE,
|
||||||
|
page * MESSAGE_PAGE_SIZE,
|
||||||
|
);
|
||||||
|
if (requestId !== messageRequestIdRef.current) return;
|
||||||
const sorted = (messagesRes.messages ?? []).sort(
|
const sorted = (messagesRes.messages ?? []).sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
|
||||||
);
|
);
|
||||||
setMessages(sorted);
|
setMessages(sorted);
|
||||||
|
setMessageTotal(messagesRes.total ?? 0);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const analysisParams = new URLSearchParams();
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
analysisParams.set('startTime', sorted[0].timestamp);
|
||||||
|
analysisParams.set('endTime', sorted[sorted.length - 1].timestamp);
|
||||||
|
}
|
||||||
const analysisRes = await httpClient.get<{
|
const analysisRes = await httpClient.get<{
|
||||||
tool_calls?: SessionToolCall[];
|
tool_calls?: SessionToolCall[];
|
||||||
}>(
|
}>(
|
||||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
|
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis?${analysisParams.toString()}`,
|
||||||
);
|
);
|
||||||
|
if (requestId !== messageRequestIdRef.current) return;
|
||||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
setToolCalls(analysisRes?.tool_calls ?? []);
|
||||||
} catch (analysisError) {
|
} catch (analysisError) {
|
||||||
|
if (requestId !== messageRequestIdRef.current) return;
|
||||||
console.error('Failed to load session tool calls:', analysisError);
|
console.error('Failed to load session tool calls:', analysisError);
|
||||||
setToolCalls([]);
|
setToolCalls([]);
|
||||||
}
|
}
|
||||||
@@ -259,6 +320,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
}>(
|
}>(
|
||||||
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
|
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
|
||||||
);
|
);
|
||||||
|
if (requestId !== messageRequestIdRef.current) return;
|
||||||
|
|
||||||
const map: Record<string, SessionFeedback> = {};
|
const map: Record<string, SessionFeedback> = {};
|
||||||
if (feedbackRes?.feedback) {
|
if (feedbackRes?.feedback) {
|
||||||
@@ -273,10 +335,14 @@ const BotSessionMonitor = forwardRef<
|
|||||||
setFeedbackMap({});
|
setFeedbackMap({});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (requestId === messageRequestIdRef.current) {
|
||||||
console.error('Failed to load session messages:', error);
|
console.error('Failed to load session messages:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === messageRequestIdRef.current) {
|
||||||
setLoadingMessages(false);
|
setLoadingMessages(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[botId],
|
[botId],
|
||||||
);
|
);
|
||||||
@@ -285,16 +351,24 @@ const BotSessionMonitor = forwardRef<
|
|||||||
loadSessions();
|
loadSessions();
|
||||||
}, [loadSessions]);
|
}, [loadSessions]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedSessionId(null);
|
||||||
|
setMessagePage(0);
|
||||||
|
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSessionId) {
|
if (selectedSessionId) {
|
||||||
loadMessages(selectedSessionId);
|
loadMessages(selectedSessionId, messagePage);
|
||||||
} else {
|
} else {
|
||||||
|
messageRequestIdRef.current += 1;
|
||||||
|
setLoadingMessages(false);
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setMessageTotal(0);
|
||||||
setToolCalls([]);
|
setToolCalls([]);
|
||||||
setExpandedToolCallIds({});
|
setExpandedToolCallIds({});
|
||||||
setFeedbackMap({});
|
setFeedbackMap({});
|
||||||
}
|
}
|
||||||
}, [selectedSessionId, loadMessages]);
|
}, [selectedSessionId, messagePage, loadMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0 && toolCalls.length === 0) return;
|
if (messages.length === 0 && toolCalls.length === 0) return;
|
||||||
@@ -552,6 +626,19 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const selectedSession = sessions.find(
|
const selectedSession = sessions.find(
|
||||||
(s) => s.session_id === selectedSessionId,
|
(s) => s.session_id === selectedSessionId,
|
||||||
);
|
);
|
||||||
|
const sessionPageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(sessionTotal / SESSION_PAGE_SIZE),
|
||||||
|
);
|
||||||
|
const messagePageCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(messageTotal / MESSAGE_PAGE_SIZE),
|
||||||
|
);
|
||||||
|
|
||||||
|
const applyUserSearch = () => {
|
||||||
|
setSessionPage(0);
|
||||||
|
setAppliedUserQuery(userQuery.trim());
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -575,6 +662,65 @@ const BotSessionMonitor = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||||
|
{t('bots.sessionMonitor.totalSessions', {
|
||||||
|
defaultValue: '{{count}} sessions',
|
||||||
|
count: sessionTotal,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-1.5 border-b shrink-0 space-y-1.5">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<input
|
||||||
|
value={userQuery}
|
||||||
|
onChange={(event) => setUserQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) =>
|
||||||
|
event.key === 'Enter' && applyUserSearch()
|
||||||
|
}
|
||||||
|
aria-label={t('bots.sessionMonitor.userSearch', {
|
||||||
|
defaultValue: 'User ID or name',
|
||||||
|
})}
|
||||||
|
placeholder={t('bots.sessionMonitor.userSearch', {
|
||||||
|
defaultValue: 'User ID or name',
|
||||||
|
})}
|
||||||
|
className="h-7 min-w-0 flex-1 rounded border bg-background px-2 text-xs"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={applyUserSearch}
|
||||||
|
className="h-7 rounded border px-2 text-[11px] hover:bg-accent"
|
||||||
|
>
|
||||||
|
{t('common.search', { defaultValue: 'Search' })}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={startDate}
|
||||||
|
max={endDate || undefined}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSessionPage(0);
|
||||||
|
setStartDate(event.target.value);
|
||||||
|
}}
|
||||||
|
aria-label={t('bots.sessionMonitor.startDate', {
|
||||||
|
defaultValue: 'Start date',
|
||||||
|
})}
|
||||||
|
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={endDate}
|
||||||
|
min={startDate || undefined}
|
||||||
|
onChange={(event) => {
|
||||||
|
setSessionPage(0);
|
||||||
|
setEndDate(event.target.value);
|
||||||
|
}}
|
||||||
|
aria-label={t('bots.sessionMonitor.endDate', {
|
||||||
|
defaultValue: 'End date',
|
||||||
|
})}
|
||||||
|
className="h-7 min-w-0 rounded border bg-background px-1 text-[10px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Session List */}
|
{/* Session List */}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
@@ -601,7 +747,10 @@ const BotSessionMonitor = forwardRef<
|
|||||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
|
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
|
||||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedSessionId(session.session_id)}
|
onClick={() => {
|
||||||
|
setSelectedSessionId(session.session_id);
|
||||||
|
setMessagePage(0);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
<span className="text-sm font-medium truncate mr-2">
|
<span className="text-sm font-medium truncate mr-2">
|
||||||
@@ -637,6 +786,29 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<div className="h-8 border-t px-1.5 flex items-center justify-between shrink-0 text-[11px]">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.previous', { defaultValue: 'Previous' })}
|
||||||
|
disabled={sessionPage === 0 || loadingSessions}
|
||||||
|
onClick={() => setSessionPage((page) => Math.max(0, page - 1))}
|
||||||
|
className="p-1 rounded hover:bg-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{sessionPage + 1} / {sessionPageCount}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('common.next', { defaultValue: 'Next' })}
|
||||||
|
disabled={sessionPage + 1 >= sessionPageCount || loadingSessions}
|
||||||
|
onClick={() => setSessionPage((page) => page + 1)}
|
||||||
|
className="p-1 rounded hover:bg-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Panel: Messages */}
|
{/* Right Panel: Messages */}
|
||||||
@@ -975,6 +1147,33 @@ const BotSessionMonitor = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
<div className="h-9 border-t px-3 flex items-center justify-center gap-3 shrink-0 text-xs">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={messagePage === 0 || loadingMessages}
|
||||||
|
onClick={() =>
|
||||||
|
setMessagePage((page) => Math.max(0, page - 1))
|
||||||
|
}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-3.5" />
|
||||||
|
{t('common.previous', { defaultValue: 'Previous' })}
|
||||||
|
</button>
|
||||||
|
<span className="tabular-nums text-muted-foreground">
|
||||||
|
{messagePage + 1} / {messagePageCount} · {messageTotal}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
messagePage + 1 >= messagePageCount || loadingMessages
|
||||||
|
}
|
||||||
|
onClick={() => setMessagePage((page) => page + 1)}
|
||||||
|
className="inline-flex items-center gap-1 rounded px-2 py-1 hover:bg-accent disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t('common.next', { defaultValue: 'Next' })}
|
||||||
|
<ChevronRight className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -474,8 +474,13 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
|
|
||||||
public getBotSessions(
|
public getBotSessions(
|
||||||
botId: string,
|
botId: string,
|
||||||
limit: number = 100,
|
options: {
|
||||||
offset: number = 0,
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
userQuery?: string;
|
||||||
|
},
|
||||||
): Promise<{
|
): Promise<{
|
||||||
sessions: Array<{
|
sessions: Array<{
|
||||||
session_id: string;
|
session_id: string;
|
||||||
@@ -495,8 +500,17 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
}> {
|
}> {
|
||||||
const queryParams = new URLSearchParams();
|
const queryParams = new URLSearchParams();
|
||||||
queryParams.append('botId', botId);
|
queryParams.append('botId', botId);
|
||||||
queryParams.append('limit', limit.toString());
|
queryParams.append('limit', options.limit.toString());
|
||||||
queryParams.append('offset', offset.toString());
|
queryParams.append('offset', options.offset.toString());
|
||||||
|
if (options.startTime) {
|
||||||
|
queryParams.append('startTime', options.startTime);
|
||||||
|
}
|
||||||
|
if (options.endTime) {
|
||||||
|
queryParams.append('endTime', options.endTime);
|
||||||
|
}
|
||||||
|
if (options.userQuery) {
|
||||||
|
queryParams.append('userQuery', options.userQuery);
|
||||||
|
}
|
||||||
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
|
return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1351,6 +1365,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
public async bindSpaceAccount(
|
public async bindSpaceAccount(
|
||||||
code: string,
|
code: string,
|
||||||
state: string,
|
state: string,
|
||||||
|
redirectUri: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
token: string;
|
token: string;
|
||||||
user: string;
|
user: string;
|
||||||
@@ -1358,7 +1373,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
}> {
|
}> {
|
||||||
const response = await this.instance.post(
|
const response = await this.instance.post(
|
||||||
'/api/v1/user/bind-space',
|
'/api/v1/user/bind-space',
|
||||||
{ code, state },
|
{ code, state, redirect_uri: redirectUri },
|
||||||
{ skipWorkspace: true } as RequestConfig,
|
{ skipWorkspace: true } as RequestConfig,
|
||||||
);
|
);
|
||||||
if (response.data.code !== 0) {
|
if (response.data.code !== 0) {
|
||||||
@@ -1394,6 +1409,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
public async exchangeSpaceOAuthCode(
|
public async exchangeSpaceOAuthCode(
|
||||||
code: string,
|
code: string,
|
||||||
state: string,
|
state: string,
|
||||||
|
redirectUri: string,
|
||||||
workspaceUuid?: string,
|
workspaceUuid?: string,
|
||||||
launchAssertion?: string,
|
launchAssertion?: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
@@ -1408,6 +1424,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
{
|
{
|
||||||
code,
|
code,
|
||||||
state,
|
state,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
workspace_uuid: workspaceUuid,
|
workspace_uuid: workspaceUuid,
|
||||||
launch_assertion: launchAssertion,
|
launch_assertion: launchAssertion,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const source = fs.readFileSync(
|
||||||
|
new URL('../../src/app/login/page.tsx', import.meta.url),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
test('normal Cloud login uses the standard Space OAuth callback path', () => {
|
||||||
|
assert.doesNotMatch(source, /cloudEntry/);
|
||||||
|
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('invitation login uses the same OAuth callback before accepting the invitation', () => {
|
||||||
|
assert.doesNotMatch(source, /cloudEntry/);
|
||||||
|
assert.match(source, /const invitationToken = getPendingInvitationToken\(\)/);
|
||||||
|
assert.match(source, /acceptWorkspaceInvitation\(invitationToken\)/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import vm from 'node:vm';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const testDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const widgetPath = path.resolve(
|
||||||
|
testDirectory,
|
||||||
|
'../../../src/langbot/templates/embed/widget.js',
|
||||||
|
);
|
||||||
|
const widgetSource = fs.readFileSync(widgetPath, 'utf8');
|
||||||
|
|
||||||
|
class FakeElement {
|
||||||
|
constructor(tagName) {
|
||||||
|
this.tagName = tagName;
|
||||||
|
this.children = [];
|
||||||
|
this.className = '';
|
||||||
|
this.dataset = {};
|
||||||
|
this.style = {};
|
||||||
|
this.listeners = {};
|
||||||
|
this._innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
appendChild(child) {
|
||||||
|
this.children.push(child);
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
|
||||||
|
setAttribute(name, value) {
|
||||||
|
this[name] = String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
addEventListener(event, listener) {
|
||||||
|
this.listeners[event] = listener;
|
||||||
|
}
|
||||||
|
|
||||||
|
click() {
|
||||||
|
this.listeners.click?.({});
|
||||||
|
}
|
||||||
|
|
||||||
|
attachShadow() {
|
||||||
|
this.shadowRoot = new FakeElement('shadow-root');
|
||||||
|
return this.shadowRoot;
|
||||||
|
}
|
||||||
|
|
||||||
|
get classList() {
|
||||||
|
return {
|
||||||
|
add: (...names) => {
|
||||||
|
const classes = `${this.className} ${names.join(' ')}`
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/);
|
||||||
|
this.className = [...new Set(classes)].join(' ');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
set textContent(value) {
|
||||||
|
this._innerHTML = String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
set innerHTML(value) {
|
||||||
|
this._innerHTML = String(value ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
get innerHTML() {
|
||||||
|
return this._innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelectorAll(selector) {
|
||||||
|
const matches = [];
|
||||||
|
for (const child of this.children) {
|
||||||
|
if (
|
||||||
|
selector.startsWith('.') &&
|
||||||
|
child.className.split(/\s+/).includes(selector.slice(1))
|
||||||
|
) {
|
||||||
|
matches.push(child);
|
||||||
|
}
|
||||||
|
matches.push(...child.querySelectorAll(selector));
|
||||||
|
}
|
||||||
|
return matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
querySelector(selector) {
|
||||||
|
return this.querySelectorAll(selector)[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeDocument {
|
||||||
|
constructor() {
|
||||||
|
this.body = new FakeElement('body');
|
||||||
|
this.head = new FakeElement('head');
|
||||||
|
this.readyState = 'complete';
|
||||||
|
this.currentScript = { getAttribute: () => null };
|
||||||
|
}
|
||||||
|
|
||||||
|
createElement(tagName) {
|
||||||
|
return new FakeElement(tagName);
|
||||||
|
}
|
||||||
|
|
||||||
|
getElementById(id) {
|
||||||
|
const find = (element) => {
|
||||||
|
if (element.id === id) return element;
|
||||||
|
for (const child of element.children) {
|
||||||
|
const match = find(child);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
return find(this.body) ?? find(this.head);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FakeWebSocket {
|
||||||
|
static OPEN = 1;
|
||||||
|
static instances = [];
|
||||||
|
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = FakeWebSocket.OPEN;
|
||||||
|
FakeWebSocket.instances.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
send() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function launchWidget() {
|
||||||
|
FakeWebSocket.instances = [];
|
||||||
|
const document = new FakeDocument();
|
||||||
|
const window = {
|
||||||
|
crypto: { randomUUID: () => '00000000-0000-4000-8000-000000000000' },
|
||||||
|
sessionStorage: { getItem: () => null, setItem: () => {} },
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
document,
|
||||||
|
window,
|
||||||
|
navigator: { clipboard: { writeText: () => Promise.resolve() } },
|
||||||
|
WebSocket: FakeWebSocket,
|
||||||
|
fetch: () => new Promise(() => {}),
|
||||||
|
requestAnimationFrame: () => 0,
|
||||||
|
setTimeout: () => 0,
|
||||||
|
clearTimeout: () => {},
|
||||||
|
setInterval: () => 0,
|
||||||
|
clearInterval: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
vm.runInContext(widgetSource, context, { filename: widgetPath });
|
||||||
|
const root = document.getElementById('langbot-widget-root');
|
||||||
|
assert.ok(root, 'widget should initialize');
|
||||||
|
root.shadowRoot.querySelector('.lb-bubble').click();
|
||||||
|
const socket = FakeWebSocket.instances.at(-1);
|
||||||
|
assert.ok(socket, 'opening widget should connect its WebSocket');
|
||||||
|
|
||||||
|
return {
|
||||||
|
receive(message) {
|
||||||
|
socket.onmessage({
|
||||||
|
data: JSON.stringify({ type: 'response', data: message }),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
assistantMessages() {
|
||||||
|
return root.shadowRoot.querySelectorAll('.lb-msg-assistant');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistant(id, content) {
|
||||||
|
return { id, role: 'assistant', content, is_final: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders a non-empty reply after an empty assistant frame', () => {
|
||||||
|
const widget = launchWidget();
|
||||||
|
|
||||||
|
widget.receive(assistant('thought', ''));
|
||||||
|
widget.receive(assistant('answer', 'visible answer'));
|
||||||
|
|
||||||
|
const messages = widget.assistantMessages();
|
||||||
|
assert.equal(messages.length, 2);
|
||||||
|
assert.equal(
|
||||||
|
messages[1].querySelector('.lb-msg-bubble').innerHTML,
|
||||||
|
'visible answer',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still drops a duplicate non-empty assistant frame', () => {
|
||||||
|
const widget = launchWidget();
|
||||||
|
|
||||||
|
widget.receive(assistant('answer-1', 'same answer'));
|
||||||
|
widget.receive(assistant('answer-2', 'same answer'));
|
||||||
|
|
||||||
|
assert.equal(widget.assistantMessages().length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still keeps two distinct non-empty assistant frames', () => {
|
||||||
|
const widget = launchWidget();
|
||||||
|
|
||||||
|
widget.receive(assistant('answer-1', 'first answer'));
|
||||||
|
widget.receive(assistant('answer-2', 'second answer'));
|
||||||
|
|
||||||
|
assert.equal(widget.assistantMessages().length, 2);
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const root = path.resolve(
|
||||||
|
path.dirname(fileURLToPath(import.meta.url)),
|
||||||
|
'../..',
|
||||||
|
);
|
||||||
|
const repoRoot = path.resolve(root, '..');
|
||||||
|
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8');
|
||||||
|
const readRepo = (file) => fs.readFileSync(path.join(repoRoot, file), 'utf8');
|
||||||
|
const includes = (source, token, message) =>
|
||||||
|
assert.ok(source.includes(token), message);
|
||||||
|
|
||||||
|
test('session list request supports a server-side page and operator filters', () => {
|
||||||
|
const client = read('src/app/infra/http/BackendClient.ts');
|
||||||
|
includes(client, 'startTime?: string', 'client accepts a start date');
|
||||||
|
includes(client, 'endTime?: string', 'client accepts an end date');
|
||||||
|
includes(client, 'userQuery?: string', 'client accepts a user query');
|
||||||
|
includes(
|
||||||
|
client,
|
||||||
|
"queryParams.append('offset', options.offset.toString())",
|
||||||
|
'client sends the requested session offset',
|
||||||
|
);
|
||||||
|
includes(
|
||||||
|
client,
|
||||||
|
"queryParams.append('userQuery', options.userQuery)",
|
||||||
|
'client sends the user query',
|
||||||
|
);
|
||||||
|
|
||||||
|
const monitor = read(
|
||||||
|
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||||
|
);
|
||||||
|
for (const token of [
|
||||||
|
'SESSION_PAGE_SIZE',
|
||||||
|
'sessionTotal',
|
||||||
|
'sessionPage',
|
||||||
|
'startDate',
|
||||||
|
'endDate',
|
||||||
|
'userQuery',
|
||||||
|
]) {
|
||||||
|
includes(monitor, token, `monitor includes ${token}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session detail requests and renders a bounded message page', () => {
|
||||||
|
const monitor = read(
|
||||||
|
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||||
|
);
|
||||||
|
for (const token of [
|
||||||
|
'MESSAGE_PAGE_SIZE',
|
||||||
|
'messageTotal',
|
||||||
|
'messagePage',
|
||||||
|
'page * MESSAGE_PAGE_SIZE',
|
||||||
|
]) {
|
||||||
|
includes(monitor, token, `message pagination includes ${token}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('backend filters sessions by user id or user name in the existing endpoint', () => {
|
||||||
|
const controller = readRepo(
|
||||||
|
'src/langbot/pkg/api/http/controller/groups/monitoring.py',
|
||||||
|
);
|
||||||
|
const service = readRepo('src/langbot/pkg/api/http/service/monitoring.py');
|
||||||
|
includes(
|
||||||
|
controller,
|
||||||
|
"quart.request.args.get('userQuery')",
|
||||||
|
'route accepts userQuery',
|
||||||
|
);
|
||||||
|
includes(controller, 'user_query=user_query', 'route forwards userQuery');
|
||||||
|
includes(
|
||||||
|
service,
|
||||||
|
'user_query: str | None = None',
|
||||||
|
'service accepts userQuery',
|
||||||
|
);
|
||||||
|
includes(
|
||||||
|
service,
|
||||||
|
'MonitoringSession.user_id.ilike',
|
||||||
|
'service searches user ids',
|
||||||
|
);
|
||||||
|
includes(
|
||||||
|
service,
|
||||||
|
'MonitoringSession.user_name.ilike',
|
||||||
|
'service searches user names',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stale session and message page responses cannot overwrite the latest page', () => {
|
||||||
|
const monitor = read(
|
||||||
|
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||||
|
);
|
||||||
|
for (const token of [
|
||||||
|
'sessionRequestIdRef',
|
||||||
|
'messageRequestIdRef',
|
||||||
|
'requestId !== sessionRequestIdRef.current',
|
||||||
|
'requestId !== messageRequestIdRef.current',
|
||||||
|
'messageRequestIdRef.current += 1',
|
||||||
|
]) {
|
||||||
|
includes(monitor, token, `stale response guard includes ${token}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('changing the session page or filters clears the selected detail', () => {
|
||||||
|
const monitor = read(
|
||||||
|
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||||
|
);
|
||||||
|
includes(monitor, 'setSelectedSessionId(null)', 'selection is cleared');
|
||||||
|
includes(
|
||||||
|
monitor,
|
||||||
|
'[appliedUserQuery, botId, endDate, sessionPage, startDate]',
|
||||||
|
'page and filters invalidate the selected session',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('date filters use the operator local calendar day', () => {
|
||||||
|
const monitor = read(
|
||||||
|
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||||
|
);
|
||||||
|
includes(
|
||||||
|
monitor,
|
||||||
|
'localDateBoundaryToISOString(startDate, false)',
|
||||||
|
'local start-of-day conversion',
|
||||||
|
);
|
||||||
|
includes(
|
||||||
|
monitor,
|
||||||
|
'localDateBoundaryToISOString(endDate, true)',
|
||||||
|
'local end-of-day conversion',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('session tool calls are bounded to the visible message page', () => {
|
||||||
|
const monitor = read(
|
||||||
|
'src/app/home/bots/components/bot-session/BotSessionMonitor.tsx',
|
||||||
|
);
|
||||||
|
includes(monitor, "analysisParams.set('startTime'", 'analysis page start');
|
||||||
|
includes(monitor, "analysisParams.set('endTime'", 'analysis page end');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user