Compare commits

...

11 Commits

Author SHA1 Message Date
dadachann 2dcf373801 fix(cloud): restore stateful Space account login 2026-09-03 04:49:38 +00: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
29 changed files with 1160 additions and 57 deletions
+2 -1
View File
@@ -1349,7 +1349,8 @@
"local-agent",
"tools",
"e2b",
"nsjail"
"nsjail",
"host"
],
"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. |
| `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.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 |
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`.
- Box has a parallel `--standalone-box` flag; the Docker box host is
`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
@@ -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
usually means the user isn't in the `docker` group →
`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.
- Don't commit a non-empty `api.global_api_key` to version control.
@@ -13,6 +13,7 @@ tags:
- tools
- e2b
- nsjail
- host
skills:
- langbot-env-setup
- langbot-testing
@@ -23,7 +24,7 @@ env:
- LANGBOT_LOCAL_AGENT_PIPELINE_NAME
preconditions:
- "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."
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."
@@ -33,7 +34,7 @@ steps:
checks:
- "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 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."
- "Box status: recent_error_count is 0 after the run."
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.
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
@@ -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=nsjail` when validating nsjail.
- `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.
4. Confirm Debug Chat uses a model with function-calling ability.
5. Confirm backend logs say native sandbox tools are available.
@@ -71,7 +72,7 @@ Backend logs should show:
- `register_skill`
- `activate`
- 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:
@@ -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.
- 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.
- 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.
## Related Troubleshooting
@@ -3,7 +3,7 @@ title: "Native sandbox tools are unavailable even though a backend is configured
date: 2026-05-18
symptoms:
- "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."
patterns:
- "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."
- "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 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."
related_cases:
- sandbox-skill-authoring-e2e
+3 -2
View File
@@ -697,9 +697,10 @@ class DingTalkClient:
if not await self.check_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:
cardData['config'] = json.dumps(card_data_config)
template_params['config'] = card_data_config
cardData: dict = {'cardParamMap': _stringify_card_param_map(template_params)}
body: dict = {
'cardTemplateId': card_template_id,
@@ -218,6 +218,7 @@ class MonitoringRouterGroup(group.RouterGroup):
pipeline_ids = quart.request.args.getlist('pipelineId')
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
user_query = quart.request.args.get('userQuery')
is_active_str = quart.request.args.get('isActive')
limit = int(quart.request.args.get('limit', 100))
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,
start_time=start_time,
end_time=end_time,
user_query=user_query,
is_active=is_active,
limit=limit,
offset=offset,
@@ -396,7 +398,14 @@ class MonitoringRouterGroup(group.RouterGroup):
@self.route('/sessions/<session_id>/analysis', methods=['GET'], permission=Permission.RESOURCE_VIEW)
async def get_session_analysis(session_id: str, request_context: RequestContext) -> str:
"""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
# The frontend will handle the 'found: false' case
@@ -9,6 +9,7 @@ from .. import group
from .....entity.errors import account as account_errors
from ...context import RequestContext
from .....cloud.launch import SpaceLaunchError
from .....workspace.errors import WorkspaceNotFoundError
from ...service.user import ControlPlaneDirectoryRequiredError, PublicRegistrationClosedError
@@ -429,13 +430,48 @@ class UserRouterGroup(group.RouterGroup):
)
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
projection_service = self.ap.directory_projection_service
access = None
# A first Cloud launch creates the personal Workspace immediately
# before redirecting here. Pull a bounded number of signed event
# pages until both the Account and its target Workspace membership
# are visible instead of rejecting during the background-sync window.
for attempt in range(4):
if account is not None:
self.ap.user_service._require_active_account(account)
try:
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
account.uuid,
launch['workspace_uuid'],
)
break
except WorkspaceNotFoundError:
if projection_service is None:
raise
elif projection_service is None:
break
if attempt == 3:
break
await projection_service.sync_once()
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if access is None and projection_service is not None:
# The target event may be deeper than the bounded incremental
# page budget. One authoritative signed snapshot catches this
# process up without turning the callback into unbounded polling.
await projection_service.refresh_snapshot()
account = await self.ap.user_service.get_user_by_uuid(launch['account_uuid'])
if account is not None:
self.ap.user_service._require_active_account(account)
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
account.uuid,
launch['workspace_uuid'],
)
if account is None:
raise SpaceLaunchError('Launch Account is not projected into Core')
self.ap.user_service._require_active_account(account)
access = await self.ap.workspace_collaboration_service.resolve_account_workspace(
account.uuid,
launch['workspace_uuid'],
)
if access is None: # pragma: no cover - bounded loop resolves or raises.
raise SpaceLaunchError('Launch Workspace is not projected into Core')
token = await self.ap.user_service.generate_jwt_token(account)
return self.success(
data={
+20 -4
View File
@@ -1257,6 +1257,7 @@ class MonitoringService:
pipeline_ids: list[str] | None = None,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
user_query: str | None = None,
is_active: bool | None = None,
limit: int = 100,
offset: int = 0,
@@ -1274,6 +1275,14 @@ class MonitoringService:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if 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:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
@@ -1365,6 +1374,8 @@ class MonitoringService:
self,
context: TenantContext,
session_id: str,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
) -> dict:
"""Get bounded session details with full statistics computed in SQL."""
workspace_uuid = require_workspace_uuid(context)
@@ -1478,12 +1489,17 @@ class MonitoringService:
)
)
tool_stats = tool_stats_result.one()
tool_conditions = [
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
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(
persistence_monitoring.MonitoringToolCall.workspace_uuid == workspace_uuid,
persistence_monitoring.MonitoringToolCall.session_id == session_id,
)
.where(*tool_conditions)
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
.limit(detail_limit + 1)
)
+10 -3
View File
@@ -455,7 +455,9 @@ class BoxService:
async def _require_validated_workspace_sandbox(self, execution_context: ExecutionContext) -> None:
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._admission is None:
raise BoxAdmissionError('Cloud Box sandbox admission is unavailable')
@@ -565,7 +567,9 @@ class BoxService:
skip_host_mount_validation: bool = False,
) -> dict:
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))
spec_payload = self._managed_policy_payload(execution_context, spec_payload)
await self._require_validated_workspace_sandbox(execution_context)
@@ -2142,5 +2146,8 @@ class BoxService:
if backend_name:
payload['connector_error'] = f'Configured sandbox backend "{backend_name}" is unavailable'
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
+16 -1
View File
@@ -125,10 +125,21 @@ class DirectoryProjectionService:
# The database cursor remains the shared projection high-water mark,
# while this cursor tracks what this process has actually observed.
self._consumer_cursor: int | None = None
self._sync_lock = asyncio.Lock()
async def initialize(self) -> None:
"""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
for _attempt in range(5):
snapshot = await self.provider.fetch_snapshot(self.instance_uuid)
@@ -159,9 +170,13 @@ class DirectoryProjectionService:
delay = min(max(delay * 2, self.sync_interval_seconds), self.max_staleness_seconds / 2)
async def sync_once(self) -> None:
async with self._sync_lock:
await self._sync_once()
async def _sync_once(self) -> None:
cursor = self._consumer_cursor
if cursor is None:
await self.initialize()
await self._refresh_snapshot()
return
batch = await self.provider.fetch_events(
self.instance_uuid,
+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)
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:
field_type = _dify_field_type(field)
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_inputs[card_id] = dict(form_data.get('inputs') or {})
else:
# Normal finish: keep pre-pause + resume content visible,
# remove buttons/notice, drop the resume placeholder.
# Normal finish: remove buttons/notice and finalize the card.
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(
card_id=card_id,
message_source=message_source,
text_message=pre_pause,
text_message=main_text,
sequence=final_seq,
form_data=None,
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.card_id_dict.pop(message_id, None)
@@ -573,7 +573,7 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
levels = ['provider_default', 'disabled', 'enabled']
elif family == 'doubao':
levels = ['provider_default', 'disabled', 'low', 'medium', 'high']
elif family == 'ollama':
elif family in ('ollama', 'ollama_chat'):
levels = ['provider_default']
levels.append('disabled')
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] = {},
) -> tuple[list[list[float]], dict]:
"""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()
args = {
@@ -1541,6 +1548,12 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
event_hooks=httpclient.httpx_response_limit_hooks(),
) as client:
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()
payload = await httpclient.parse_json_response(response)
@@ -7,7 +7,7 @@ metadata:
zh_Hans: Ollama
icon: ollama.svg
spec:
litellm_provider: ollama
litellm_provider: ollama_chat
config:
- name: base_url
label:
@@ -222,6 +222,7 @@ class NativeToolLoader(loader.ToolLoader):
self.ap.logger.warning(
'Native sandbox tools (exec/read/write/edit/glob/grep) are NOT available. '
'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.'
)
@@ -42,7 +42,8 @@ class SkillToolLoader(loader.ToolLoader):
else:
self.ap.logger.info(
'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:
+4 -1
View File
@@ -331,7 +331,10 @@ box:
# 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.
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:
# LANGBOT_BOX_CONTROL_TOKEN is optional for OSS external WebSocket
# runtimes. To protect an exposed endpoint, set the same strong secret
+4 -3
View File
@@ -642,9 +642,10 @@
.replace(/\s+/g, " ")
.trim();
if (
prevContent === content ||
prevContent.indexOf(content) >= 0 ||
content.indexOf(prevContent) >= 0
prevContent &&
(prevContent === content ||
prevContent.indexOf(content) >= 0 ||
content.indexOf(prevContent) >= 0)
)
return;
}
+24 -2
View File
@@ -242,6 +242,22 @@ class TestMonitoringSessionsEndpoint:
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')
class TestMonitoringErrorsEndpoint:
@@ -278,13 +294,19 @@ class TestMonitoringDetailsEndpoints:
"""Tests for detail endpoints."""
@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."""
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
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
async def test_get_message_details(self, quart_test_client):
+106 -1
View File
@@ -11,6 +11,7 @@ import pytest
import quart
from langbot.pkg.api.http.controller.groups.user import UserRouterGroup
from langbot.pkg.workspace.errors import WorkspaceNotFoundError
pytestmark = pytest.mark.integration
@@ -27,7 +28,8 @@ async def space_oauth_api():
execution=SimpleNamespace(instance_uuid='instance-a', placement_generation=1),
)
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.user_service.get_authenticated_account = AsyncMock(return_value=account)
application.user_service.issue_space_oauth_state = AsyncMock(
@@ -69,6 +71,7 @@ async def space_oauth_api():
application.space_service.get_oauth_authorize_url = Mock(
side_effect=lambda redirect_uri, state: f'https://space.example/authorize?state={state}'
)
application.space_service.exchange_oauth_code = AsyncMock(
return_value={
'access_token': 'space-access-token',
@@ -125,6 +128,26 @@ async def test_cloud_launch_state_is_server_issued_and_workspace_bound(space_oau
)
@pytest.mark.asyncio
async def test_cloud_login_entry_starts_stateful_space_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
async def test_public_login_rejects_caller_supplied_state(space_oauth_api):
application, client = space_oauth_api
@@ -414,3 +437,85 @@ async def test_direct_launch_assertion_does_not_consume_normal_oauth_state(space
)
application.user_service.consume_space_oauth_state.assert_not_awaited()
application.space_service.exchange_oauth_code.assert_not_awaited()
@pytest.mark.asyncio
async def test_direct_launch_refreshes_new_workspace_projection_before_rejecting_account(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(side_effect=[None, None, projected_account])
application.directory_projection_service = SimpleNamespace(sync_once=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
assert application.directory_projection_service.sync_once.await_count == 2
assert application.user_service.get_user_by_uuid.await_count == 3
@pytest.mark.asyncio
async def test_direct_launch_refreshes_projection_when_account_exists_before_workspace(space_oauth_api):
application, client = space_oauth_api
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=[WorkspaceNotFoundError('Workspace not found'), projected_access]
)
application.directory_projection_service = SimpleNamespace(sync_once=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.sync_once.assert_awaited_once_with()
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 2
@pytest.mark.asyncio
async def test_direct_launch_falls_back_to_snapshot_when_event_backlog_exceeds_page_budget(space_oauth_api):
application, client = space_oauth_api
projected_access = application.workspace_collaboration_service.resolve_account_workspace.return_value
application.workspace_collaboration_service.resolve_account_workspace = AsyncMock(
side_effect=[
WorkspaceNotFoundError('Workspace not found'),
WorkspaceNotFoundError('Workspace not found'),
WorkspaceNotFoundError('Workspace not found'),
WorkspaceNotFoundError('Workspace not found'),
projected_access,
]
)
application.directory_projection_service = SimpleNamespace(
sync_once=AsyncMock(),
refresh_snapshot=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
assert application.directory_projection_service.sync_once.await_count == 3
application.directory_projection_service.refresh_snapshot.assert_awaited_once_with()
assert application.workspace_collaboration_service.resolve_account_workspace.await_count == 5
@@ -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
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):
context = _context(WORKSPACE_A)
message_id = await _record_message(service, context, 'tool context')
@@ -1,5 +1,6 @@
from __future__ import annotations
import asyncio
import datetime
import logging
from types import SimpleNamespace
@@ -735,6 +736,72 @@ async def test_each_replica_consumes_events_with_its_own_cursor(projection_conte
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):
application, session_factory = projection_context
event_two = DirectoryEvent(
+42 -1
View File
@@ -1,8 +1,11 @@
"""Tests for DingTalk API payload helpers."""
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():
@@ -40,3 +43,41 @@ def test_dingtalk_card_param_map_stringifies_unregistered_structures():
assert params['other'] == '["A"]'
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."""
import threading
from unittest.mock import MagicMock
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -12,6 +12,7 @@ from langbot.pkg.platform.sources.lark import (
_lark_completed_input_lines,
_lark_current_input_defs,
_lark_extract_action_form_inputs,
_lark_final_layout_texts,
_lark_should_update_stream_element,
_lark_visible_form_content,
)
@@ -221,3 +222,121 @@ def test_lark_completed_input_lines_display_select_value_from_object():
)
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'
@@ -17,6 +17,7 @@ import {
Copy,
Check,
ChevronDown,
ChevronLeft,
ChevronRight,
Workflow,
ThumbsUp,
@@ -117,16 +118,43 @@ interface BotSessionMonitorProps {
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<
BotSessionMonitorHandle,
BotSessionMonitorProps
>(function BotSessionMonitor({ botId }, ref) {
const { t } = useTranslation();
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>(
null,
);
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [messageTotal, setMessageTotal] = useState(0);
const [messagePage, setMessagePage] = useState(0);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const [copiedUserId, setCopiedUserId] = useState(false);
@@ -138,6 +166,8 @@ const BotSessionMonitor = forwardRef<
Record<string, boolean>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const sessionRequestIdRef = useRef(0);
const messageRequestIdRef = useRef(0);
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
@@ -204,16 +234,33 @@ const BotSessionMonitor = forwardRef<
};
const loadSessions = useCallback(async () => {
const requestId = ++sessionRequestIdRef.current;
setLoadingSessions(true);
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 ?? []);
setSessionTotal(response.total ?? 0);
} catch (error) {
console.error('Failed to load sessions:', error);
if (requestId === sessionRequestIdRef.current) {
console.error('Failed to load sessions:', error);
}
} finally {
setLoadingSessions(false);
if (requestId === sessionRequestIdRef.current) {
setLoadingSessions(false);
}
}
}, [botId]);
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
useImperativeHandle(
ref,
@@ -224,25 +271,39 @@ const BotSessionMonitor = forwardRef<
);
const loadMessages = useCallback(
async (sessionId: string) => {
async (sessionId: string, page: number) => {
const requestId = ++messageRequestIdRef.current;
setLoadingMessages(true);
setExpandedToolCallIds({});
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(
(a, b) =>
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(),
);
setMessages(sorted);
setMessageTotal(messagesRes.total ?? 0);
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<{
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 ?? []);
} catch (analysisError) {
if (requestId !== messageRequestIdRef.current) return;
console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]);
}
@@ -259,6 +320,7 @@ const BotSessionMonitor = forwardRef<
}>(
`/api/v1/monitoring/feedback?botId=${encodeURIComponent(botId)}&limit=200`,
);
if (requestId !== messageRequestIdRef.current) return;
const map: Record<string, SessionFeedback> = {};
if (feedbackRes?.feedback) {
@@ -273,9 +335,13 @@ const BotSessionMonitor = forwardRef<
setFeedbackMap({});
}
} catch (error) {
console.error('Failed to load session messages:', error);
if (requestId === messageRequestIdRef.current) {
console.error('Failed to load session messages:', error);
}
} finally {
setLoadingMessages(false);
if (requestId === messageRequestIdRef.current) {
setLoadingMessages(false);
}
}
},
[botId],
@@ -285,16 +351,24 @@ const BotSessionMonitor = forwardRef<
loadSessions();
}, [loadSessions]);
useEffect(() => {
setSelectedSessionId(null);
setMessagePage(0);
}, [appliedUserQuery, botId, endDate, sessionPage, startDate]);
useEffect(() => {
if (selectedSessionId) {
loadMessages(selectedSessionId);
loadMessages(selectedSessionId, messagePage);
} else {
messageRequestIdRef.current += 1;
setLoadingMessages(false);
setMessages([]);
setMessageTotal(0);
setToolCalls([]);
setExpandedToolCallIds({});
setFeedbackMap({});
}
}, [selectedSessionId, loadMessages]);
}, [selectedSessionId, messagePage, loadMessages]);
useEffect(() => {
if (messages.length === 0 && toolCalls.length === 0) return;
@@ -552,6 +626,19 @@ const BotSessionMonitor = forwardRef<
const selectedSession = sessions.find(
(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 (
<>
@@ -575,6 +662,65 @@ const BotSessionMonitor = forwardRef<
)}
</span>
</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>
{/* Session List */}
<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',
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">
<span className="text-sm font-medium truncate mr-2">
@@ -637,6 +786,29 @@ const BotSessionMonitor = forwardRef<
</div>
)}
</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>
{/* Right Panel: Messages */}
@@ -975,6 +1147,33 @@ const BotSessionMonitor = forwardRef<
)}
</div>
</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>
+21 -5
View File
@@ -474,8 +474,13 @@ export class BackendClient extends BaseHttpClient {
public getBotSessions(
botId: string,
limit: number = 100,
offset: number = 0,
options: {
limit: number;
offset: number;
startTime?: string;
endTime?: string;
userQuery?: string;
},
): Promise<{
sessions: Array<{
session_id: string;
@@ -495,8 +500,17 @@ export class BackendClient extends BaseHttpClient {
}> {
const queryParams = new URLSearchParams();
queryParams.append('botId', botId);
queryParams.append('limit', limit.toString());
queryParams.append('offset', offset.toString());
queryParams.append('limit', options.limit.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()}`);
}
@@ -1376,7 +1390,9 @@ export class BackendClient extends BaseHttpClient {
}> {
return this.get(
'/api/v1/user/space/authorize-url',
{ redirect_uri: redirectUri },
{
redirect_uri: redirectUri,
},
{ skipWorkspace: true },
);
}
@@ -0,0 +1,18 @@
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 starts stateful Space OAuth', () => {
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
assert.doesNotMatch(source, /cloudEntry/);
});
test('invitation login remains on the OAuth callback path', () => {
assert.match(source, /getPendingInvitationToken\(\)/);
assert.match(source, /getSpaceAuthorizeUrl\(redirectUri\)/);
});
@@ -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');
});