Compare commits

...

13 Commits

Author SHA1 Message Date
Hyu b44b8f474d fix(cloud): provision login workspace just in time (#2505)
* fix(cloud): provision login workspace just in time

* fix(oauth): send callback URI during code exchange

* fix(oauth): preserve callback URI through browser exchange

* fix(oauth): negotiate redirect-bound codes

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-03 23:14:44 +08:00
Hyu ab52684a01 Revert "fix(cloud): request automatic Space launch (#2501)" (#2503)
This reverts commit d50957fc4f.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-03 12:16:21 +08:00
Hyu d50957fc4f fix(cloud): request automatic Space launch (#2501)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-03 11:44:32 +08:00
Hyu c8d8b1aac4 fix(cloud): serialize directory catch-up (#2500)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-02 21:57:53 +08:00
Hyu 018dd7a363 fix(cloud): launch newly registered accounts through Space (#2499)
* fix(cloud): launch new accounts through Space

* style: format Cloud entry URL

* fix(cloud): wait for launch workspace projection

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-02 21:41:35 +08:00
huanghuoguoguo 7b7d3f04e8 feat(box): support explicit host backend (#2498) 2026-09-02 21:22:14 +08:00
CWT 601c6975ea fix(ollama): use litellm's ollama_chat provider for native tool-calling (#2494)
The Ollama requester declared litellm_provider: ollama, which routes
every request through litellm's legacy /api/generate-based
OllamaConfig. That config's get_supported_openai_params() does not
include "tools"/"tool_choice" at all, so an Ollama-hosted model in a
local-agent pipeline could never receive a structured tool definition
or return a structured tool_calls response - it could only try to
express a tool call as free text (typically inside its own <think>
reasoning), which LangBot then has no way to execute.

litellm's "ollama_chat" provider targets Ollama's modern /api/chat
endpoint instead, which correctly forwards tools/tool_choice and
correctly surfaces the model's native message.tool_calls field.
Verified against a real local Ollama 0.33.2 instance with the exact
system prompt, RAG-augmented user message, and tool set a live
pipeline sends.

Two follow-on fixes needed because the Ollama requester definition is
shared by LLM and text-embedding models:

- get_reasoning_capabilities: match family in ('ollama', 'ollama_chat')
  so the reasoning-level UI still works for this provider.
- scan_models: retry {base_url}/v1/models on a 404 from {base_url}/models,
  since Ollama's base_url is a bare host (must not include /v1 - that
  would break OllamaChatConfig.get_complete_url, which appends /api/chat
  to it directly), unlike most other OpenAI-compatible providers whose
  base_url already ends in /v1.
- invoke_embedding: litellm's embedding routing has no "ollama_chat"
  case, only "ollama". Build the embedding model name with an explicit
  custom_llm_provider="ollama" override when the requester is configured
  for ollama_chat, so embedding models (e.g. bge-m3) keep working.

Co-authored-by: zx90316 <zx90316@users.noreply.github.com>
2026-09-01 22:36:35 +08:00
mintya 5ca30133a3 fix(lark): stop duplicating final reply text in streaming card (#2490)
* fix(lark): stop duplicating final reply text in streaming card

* fix(lark): stop duplicating final reply text in streaming card
2026-09-01 21:24:54 +08:00
Hyu 5c49cb60e3 fix(embed): preserve replies after empty assistant frames (#2492)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-01 20:11:21 +08:00
Hyu 8cf0015502 fix(dingtalk): restore card auto layout (#2491)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-01 17:56:02 +08:00
Hyu bf8d418ad4 feat(monitoring): paginate sessions and messages (#2489)
* feat(monitoring): paginate sessions and messages

* fix(monitoring): align detail pages with local dates

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-01 15:14:29 +08:00
Hyu 7aab0cee07 chore(release): bump version to 4.10.9 (#2488)
Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-01 00:18:53 +08:00
RockChinQ 1b7ae791b3 fix(plugin): return not found after removal (#2483)
Co-authored-by: Hyu <chenhyu@proton.me>
2026-08-31 13:40:03 +08:00
37 changed files with 1469 additions and 85 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.10.8" version = "4.10.9"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -70,7 +70,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2", "langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.5", "langbot-plugin==0.5.6",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2", "matrix-nio>=0.25.2",
+2 -1
View File
@@ -1349,7 +1349,8 @@
"local-agent", "local-agent",
"tools", "tools",
"e2b", "e2b",
"nsjail" "nsjail",
"host"
], ],
"automation": "", "automation": "",
"setup_automation": [], "setup_automation": [],
+7 -1
View File
@@ -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
+3 -2
View File
@@ -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')
+20 -4
View File
@@ -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)
) )
+4 -1
View File
@@ -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.
+3 -2
View File
@@ -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)
+10 -3
View File
@@ -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
+100 -2
View File
@@ -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)
+32 -4
View File
@@ -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)
+6 -1
View File
@@ -1913,9 +1913,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
return plugins return plugins
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]: async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any] | None:
runtime_handler = self._runtime_handler() runtime_handler = self._runtime_handler()
try:
binding = await self._target_binding(author, plugin_name) binding = await self._target_binding(author, plugin_name)
except ValueError as exc:
if str(exc) == f'Plugin {author}/{plugin_name} is not installed in this Workspace':
return None
raise
with runtime_handler.installation_scope(binding): with runtime_handler.installation_scope(binding):
return await runtime_handler.get_plugin_info(author, plugin_name) return await runtime_handler.get_plugin_info(author, plugin_name)
@@ -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:
+4 -1
View File
@@ -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
+3 -2
View File
@@ -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;
} }
+24 -2
View File
@@ -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):
+198 -8
View File
@@ -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(
+42 -1
View File
@@ -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'}
+120 -1
View File
@@ -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 == ['✅ xialaB'] assert lines == ['✅ xialaB']
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'
@@ -640,6 +640,19 @@ class TestGetPluginInfo:
connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin') connector.handler.get_plugin_info.assert_called_once_with('author', 'plugin')
assert result == {'manifest': {'metadata': {'name': 'plugin'}}} assert result == {'manifest': {'metadata': {'name': 'plugin'}}}
@pytest.mark.asyncio
async def test_returns_none_when_plugin_is_not_installed(self):
connector = create_mock_connector()
configure_handler(connector, AsyncMock())
connector._target_binding = AsyncMock(
side_effect=ValueError('Plugin author/plugin is not installed in this Workspace')
)
result = await connector.get_plugin_info('author', 'plugin')
assert result is None
connector.handler.get_plugin_info.assert_not_awaited()
class TestSetPluginConfig: class TestSetPluginConfig:
"""Tests for set_plugin_config method.""" """Tests for set_plugin_config method."""
Generated
+5 -5
View File
@@ -2008,7 +2008,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.10.8" version = "4.10.9"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -2129,7 +2129,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.5.5" }, { name = "langbot-plugin", specifier = "==0.5.6" },
{ name = "langchain", specifier = ">=1.3.9" }, { name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" }, { name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2196,7 +2196,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.5.5" version = "0.5.6"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
@@ -2217,9 +2217,9 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/c3/be/1bbdf959d8c16b625e3721cde586b3bb22eaa22dd8c22d072c04f9b491ba/langbot_plugin-0.5.5.tar.gz", hash = "sha256:ea31b0ddf64c2ef8fdec012273b2d3dee6f0d140475f07694f31ea685be40695", size = 472639, upload-time = "2026-08-16T17:33:27.783Z" } sdist = { url = "https://files.pythonhosted.org/packages/0b/1b/0c2e1f457abedf7ce052f47ad193937322b5f25f4e09e35d92bb5bd0346f/langbot_plugin-0.5.6.tar.gz", hash = "sha256:b7d6bb170ceffead6929e8d95ac388dd9a90a6d971ec4fcdaf7f7b46e894fa9e", size = 475814, upload-time = "2026-08-31T16:04:51.604Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/30/72caa601b571542fa4de5f2a3461d6f601f75c52d484d9fc95ebb82ce30c/langbot_plugin-0.5.5-py3-none-any.whl", hash = "sha256:a55d20a0c015414ef85d783b493f83d27b64f1d662887de94330df9d3d4ab64e", size = 304643, upload-time = "2026-08-16T17:33:26.687Z" }, { url = "https://files.pythonhosted.org/packages/ad/40/1bb5d3562f66c88ac45b3b5b6ee77e9f8a6943599aea95731ea4a4e8b005/langbot_plugin-0.5.6-py3-none-any.whl", hash = "sha256:8f35a07be667abeb84147c4299d7afcc394125c73455fc74d9fcc887eae3a7d4", size = 306108, upload-time = "2026-08-31T16:04:50.427Z" },
] ]
[[package]] [[package]]
+15 -3
View File
@@ -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>
+22 -5
View File
@@ -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');
});