From 0896dd65e169c83dd3302e3ba4018c7b8e5f7808 Mon Sep 17 00:00:00 2001 From: guenhter Date: Wed, 6 May 2026 06:05:22 +0200 Subject: [PATCH 1/4] docs: update prerequisites --- README.md | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f33fdcc..e3ff99a 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,7 @@ modAI-chat/ ├── backend/ │ ├── omni/ # Python FastAPI backend │ └── tools/dice-roller/ # Example tool microservice +├── docs/ # User, admin & developer documentation ├── frontend/ │ └── omni/ # Svelte TypeScript SPA └── e2e_tests/ @@ -43,9 +44,18 @@ Why more than one frontend: because there are different use cases. e.g. one full ### Prerequisites - Python 3.13+ - Node.js 24+ -- UV package manager -- Docker (for NanoIDP identity provider) -- Optional: [just](https://github.com/casey/just) +- [pnpm](http://pnpm.io) +- [uv](https://docs.astral.sh/uv) +- Docker +- [just](https://github.com/casey/just) + +For easy dependency installation of the whole repo, run + +```bash +just install +``` + +this installes the python/npm dependencies for all subprojects. ### NanoIDP Setup (Identity Provider) Start the lightweight local OIDC identity provider (runs on port 9000): @@ -59,16 +69,16 @@ nanoidp Dashboard: http://localhost:9000 ```bash cd backend/omni cp .env.sample .env -uv sync -uv run uvicorn modai.main:app --reload +just install +just start ``` ### Frontend Setup ```bash cd frontend/omni ln -sf modules_with_backend.json public/modules.json -pnpm install -pnpm dev +just install +just start ``` Browse to http://localhost:5173/ From 78ba034c0526c3ba4b4f3fb091e5c4cd5fcaa05b Mon Sep 17 00:00:00 2001 From: guenhter Date: Wed, 6 May 2026 06:04:56 +0200 Subject: [PATCH 2/4] refactor: switch from llmock to aimock --- .agents/skills/llmock/SKILL.md | 239 ------- .agents/skills/llmock/references/config.yaml | 42 -- .../docs/learnings/INSTRUCTION_UPDATES.md | 13 - backend/omni/pyproject.toml | 2 +- .../chat/__tests__/aimock-fixtures.json | 53 ++ .../chat/__tests__/test_chat_llm_modules.py | 616 +++++++++--------- backend/omni/uv.lock | 8 +- e2e_tests/tests_omni_full/justfile | 3 + .../tests_omni_full/playwright.config.ts | 11 +- .../tests_omni_full/scripts/start-aimock.sh | 16 + .../tests_omni_full/src/aimock-fixtures.json | 24 + e2e_tests/tests_omni_full/src/chat.spec.ts | 77 +-- e2e_tests/tests_omni_full/src/pages.ts | 73 ++- e2e_tests/tests_omni_light/justfile | 3 + .../tests_omni_light/playwright.config.ts | 11 +- .../tests_omni_light/scripts/start-aimock.sh | 16 + .../tests_omni_light/src/aimock-fixtures.json | 24 + e2e_tests/tests_omni_light/src/chat.spec.ts | 26 +- e2e_tests/tests_omni_light/src/pages.ts | 66 +- 19 files changed, 595 insertions(+), 728 deletions(-) delete mode 100644 .agents/skills/llmock/SKILL.md delete mode 100644 .agents/skills/llmock/references/config.yaml create mode 100644 backend/omni/src/modai/modules/chat/__tests__/aimock-fixtures.json create mode 100755 e2e_tests/tests_omni_full/scripts/start-aimock.sh create mode 100644 e2e_tests/tests_omni_full/src/aimock-fixtures.json create mode 100755 e2e_tests/tests_omni_light/scripts/start-aimock.sh create mode 100644 e2e_tests/tests_omni_light/src/aimock-fixtures.json diff --git a/.agents/skills/llmock/SKILL.md b/.agents/skills/llmock/SKILL.md deleted file mode 100644 index 76aee8b..0000000 --- a/.agents/skills/llmock/SKILL.md +++ /dev/null @@ -1,239 +0,0 @@ ---- -name: llmock-skill -description: >- - Run and configure llmock via Docker, an OpenAI-compatible mock server for - testing LLM integrations. Use when you need a local mock for OpenAI endpoints - (/models, /chat/completions, /responses), when testing tool calling, - error handling, or streaming against a deterministic server, or when - configuring mock behaviors via config.yaml and Docker environment variables. -license: MIT -metadata: - author: modAI-systems - version: "0.0.1" ---- - -# llmock — OpenAI-Compatible Mock Server (Docker) - -llmock is a lightweight Docker-based mock server that implements OpenAI's API. It lets you test LLM integrations without hitting a real API. By default it echoes input back as output (mirror strategy), and supports config-driven tool calls, error simulation, and streaming. - -## When to Use This Skill - -- You need a local OpenAI-compatible server for integration tests -- You want deterministic, reproducible responses from an "LLM" -- You need to test tool calling, error handling, or streaming logic -- You want to avoid API costs and rate limits during development - -## Running with Docker - -### Basic Start - -```bash -docker container run -p 8000:8000 ghcr.io/modai-systems/llmock:latest -``` - -The server is available at `http://localhost:8000`. Health check: `GET /health` (no auth). - -### With Custom Configuration - -The container reads config from `/app/config.yaml`. Mount a local file to override: - -```bash -docker container run -p 8000:8000 \ - -v ./config.yaml:/app/config.yaml:ro \ - ghcr.io/modai-systems/llmock:latest -``` - -### With Environment Variable Overrides - -Override individual config values using `LLMOCK_`-prefixed environment variables: - -```bash -docker container run -p 8000:8000 \ - -e LLMOCK_API_KEY=my-custom-key \ - -e LLMOCK_CORS_ALLOW_ORIGINS="http://localhost:3000;http://localhost:5173" \ - ghcr.io/modai-systems/llmock:latest -``` - -Environment variable rules: -- Nested keys joined with underscores: `cors.allow-origins` → `LLMOCK_CORS_ALLOW_ORIGINS` -- Dashes converted to underscores: `api-key` → `LLMOCK_API_KEY` -- Lists parsed from semicolon-separated values -- Only keys present in `config.yaml` are overridden - -### Verify It's Running - -```bash -curl http://localhost:8000/health -``` - -## Connecting an OpenAI Client - -Point any OpenAI SDK client at the mock server. The default API key is `your-secret-api-key`. - -```python -from openai import OpenAI - -client = OpenAI( - base_url="http://localhost:8000/", - api_key="your-secret-api-key", -) -``` - -Any language's OpenAI SDK works — just change `base_url`. - -## General Behavior - -### Default: Mirror Strategy - -Without any special config, llmock echoes the last user message back as the response. Send `"Hello!"` and get `"Hello!"` back. - -### Strategy System - -Strategies are an ordered list in `config.yaml`. They run in sequence; the **first strategy that returns a non-empty result wins**. Remaining strategies are skipped. - -```yaml -strategies: - - ErrorStrategy # Check for error triggers first - - ToolCallStrategy # Then check for tool call matches - - MirrorStrategy # Fall back to echoing input -``` - -| Strategy | Behavior | -|----------|----------| -| `MirrorStrategy` | Echoes the last user message | -| `ToolCallStrategy` | Returns tool calls triggered by `call tool '' with ''` phrase in the last user message | -| `ErrorStrategy` | Returns HTTP errors triggered by `raise error ` phrase in the last user message | - -If `strategies` is omitted, defaults to `["MirrorStrategy"]`. Unknown names are skipped with a warning. - -### Model Validation - -Requests must specify a model that exists in the `models` config list. Invalid models return a `404` error. Model validation runs **before** any strategy. - -### Authentication - -If `api-key` is set in config, clients must send `Authorization: Bearer `. If `api-key` is not set, all requests are allowed. The `/health` endpoint never requires auth. - -## Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `/models` | GET | List configured models | -| `/models/{model_id}` | GET | Retrieve a single model | -| `/chat/completions` | POST | Chat Completions API (streaming supported) | -| `/responses` | POST | Responses API (streaming supported) | -| `/health` | GET | Health check (no auth required) | - -Both `/chat/completions` and `/responses` support `stream=True` (SSE, word-level chunking) and `stream_options.include_usage` for usage stats. - -## Configuration - -The container reads `/app/config.yaml`. See [references/CONFIG.md](references/CONFIG.md) for the full field reference. - -### Minimal Custom Config - -```yaml -api-key: "test-key" -models: - - id: "gpt-4o" - created: 1715367049 - owned_by: "openai" -``` - -### Full Config with All Features - -```yaml -api-key: "your-secret-api-key" - -cors: - allow-origins: - - "http://localhost:8000" - -models: - - id: "gpt-4o" - created: 1715367049 - owned_by: "openai" - - id: "gpt-4o-mini" - created: 1721172741 - owned_by: "openai" - - id: "gpt-3.5-turbo" - created: 1677610602 - owned_by: "openai" - -strategies: - - ErrorStrategy - - ToolCallStrategy - - MirrorStrategy -``` - -### Tool Calling - -When `ToolCallStrategy` is in the strategies list, llmock scans the last user message line-by-line for the pattern: - -``` -call tool '' with '' -``` - -- `` must match one of the tools declared in the request's `tools` list. -- `` is the arguments string passed back as the tool call arguments (use `'{}'` for no arguments). -- Multiple matching lines each produce a separate tool call response. -- If no line matches, or the named tool is not in `request.tools`, the strategy returns an empty list and the next strategy runs. -- **The strategy only fires when the last message in the conversation is a `user` message.** If the last message has any other role (`assistant`, `tool`, `system`), the strategy is skipped entirely. This prevents the infinite loop that would otherwise occur when the trigger phrase persists in the conversation history across multiple cycles. - -### Error Simulation - -When `ErrorStrategy` is in the strategies list, llmock scans the last user message line-by-line for the pattern: - -``` -raise error {"code": 429, "message": "Rate limit exceeded"} -``` - -| Field | Required | Maps to | -|-------|----------|---------| -| `code` | yes (int) | HTTP response status code (e.g. `429`) | -| `message` | yes (string) | `error.message` in the JSON body | -| `type` | no (string) | `error.type` in the JSON body — defaults to `"api_error"` | -| `error_code` | no (string) | `error.code` in the JSON body — defaults to `"error"` | - -Example with all fields: - -``` -raise error {"code": 429, "message": "Rate limit exceeded", "type": "rate_limit_error", "error_code": "rate_limit_exceeded"} -``` - -Produces HTTP 429 with body: - -```json -{ - "error": { - "message": "Rate limit exceeded", - "type": "rate_limit_error", - "param": null, - "code": "rate_limit_exceeded" - } -} -``` - -- The phrase can appear anywhere in the message — the line is scanned, not the whole message. -- First matching line wins; remaining lines are ignored. -- If no line matches, the strategy returns an empty list and the next strategy runs. -- Works on both `/chat/completions` and `/responses`. - -## Default Models - -Out of the box, the container serves: - -| Model ID | Created | Owner | -|----------|---------|-------| -| `gpt-4o` | 1715367049 | openai | -| `gpt-4o-mini` | 1721172741 | openai | -| `gpt-3.5-turbo` | 1677610602 | openai | - -## Key Rules - -1. **Mirror is the default** — without tool calls or error triggers, the server echoes the last user message. -2. **Strategy order matters** — first non-empty result wins; remaining strategies are skipped. -3. **Model must be valid** — model validation runs before strategies; unknown models → 404. -4. **Auth is optional** — no `api-key` in config = all requests allowed. -5. **Config path in Docker is `/app/config.yaml`** — mount with `-v ./config.yaml:/app/config.yaml:ro`. -6. **Use `docker container` syntax** — always `docker container run`, `docker container stop`, etc. diff --git a/.agents/skills/llmock/references/config.yaml b/.agents/skills/llmock/references/config.yaml deleted file mode 100644 index 747b08e..0000000 --- a/.agents/skills/llmock/references/config.yaml +++ /dev/null @@ -1,42 +0,0 @@ -# llmock Configuration -# Each section is consumed by its respective router/component - -# Port for the HTTP server (default: 8000) -port: 8000 - -# Debug mode - when true, pretty-prints all incoming request bodies to stdout -debug: false - -# API key for authentication (optional - if not set, no auth required) -api-key: - -# CORS configuration -cors: - allow-origins: - - "http://localhost:8000" - -# Models configuration (used by models router) -models: - - id: "gpt-4o" - created: 1715367049 - owned_by: "openai" - - id: "gpt-4o-mini" - created: 1721172741 - owned_by: "openai" - - id: "gpt-3.5-turbo" - created: 1677610602 - owned_by: "openai" - -# Ordered list of strategies (first non-empty result wins) -strategies: - - ErrorStrategy - - CustomAnswersStrategy - - ToolCallStrategy - - MirrorStrategy - -# Inline question→answer pairs for CustomAnswersStrategy (exact-match, case-sensitive) -# customReplies: -# - question: How are you today? -# answer: I'm fine. how are you? -# - question: What is 1+1? -# answer: 2 diff --git a/backend/omni/docs/learnings/INSTRUCTION_UPDATES.md b/backend/omni/docs/learnings/INSTRUCTION_UPDATES.md index cb0443a..14e1306 100644 --- a/backend/omni/docs/learnings/INSTRUCTION_UPDATES.md +++ b/backend/omni/docs/learnings/INSTRUCTION_UPDATES.md @@ -28,20 +28,7 @@ This file tracks corrections provided by the user to improve future performance. - **Correction**: Tests should only verify observable behavior (e.g. construction succeeds/fails, method returns expected result), never assert on internal instance attributes. - **New Rule**: Never assert on internal object fields/state in tests. Only verify behavior: does it raise? Does the return value match expectations? Does it produce the correct side-effects? -### 2026-03-04 - No `patch` in tests — use testcontainers + pytest-httpserver -- **Mistake**: Used `unittest.mock.patch` to spy on `_create_agent` and mock `httpx.Client` to test tool handler behaviour. -- **Correction**: `patch` must not be used. The file under test should be modular enough that all external dependencies can be configured. For the LLM side use an llmock testcontainer; for tool HTTP endpoints use `pytest-httpserver`. -- **New Rule**: Never use `patch`. For tool invocation tests, configure llmock with `ToolCallStrategy` and point `ToolDefinition.url` at a `pytest-httpserver` instance. - ### 2026-03-04 - Explicit user directive: no whitebox testing anywhere - **Mistake**: Writing tests that target individual private/internal functions to achieve coverage. - **Correction**: All tests must exercise only the public interface. If internal logic needs coverage, improve public-API tests, not private-function tests. - **New Rule**: NO WHITEBOX TESTING. Never test `_prefixed` functions or assert on private object state. A test that does so is incorrect by definition and must be rewritten to go through the public API. Updated `AGENTS.md`. - -### 2026-03-13 - Injected metadata in tool params uses `_` prefix -- **Convention**: When the caller needs to pass transport-level metadata (e.g. bearer token) into `Tool.run`, inject it as a `_`-prefixed key in the `params` dict (e.g. `_bearer_token`). The implementation pops those keys before building the request body; they are never forwarded as JSON payload. -- **New Rule**: Any caller-injected, non-payload property passed via `params` MUST use a `_`-prefixed key. Document new keys in `docs/architecture/tools.md` under "Reserved `_`-prefixed keys". - -- **Mistake**: Passed `base_url = f"{root_url}/v1"` — the updated llmock no longer mounts routes under `/v1`. -- **Correction**: All llmock endpoints are now at the root (`/chat/completions`, `/models`, `/health`). Pass `base_url = f"{root_url}/"` (trailing slash) so the OpenAI SDK does not append `/v1`. -- **New Rule**: Always use `base_url = "http://:/"` (trailing slash) when connecting to llmock. The SKILL.md has been updated. diff --git a/backend/omni/pyproject.toml b/backend/omni/pyproject.toml index 016a796..22a8fc9 100644 --- a/backend/omni/pyproject.toml +++ b/backend/omni/pyproject.toml @@ -27,7 +27,7 @@ dev = [ "pytest-asyncio", "pytest-httpserver", "ruff", - "testcontainers", + "testcontainers>=4.15.0rc2", ] [tool.pytest.ini_options] diff --git a/backend/omni/src/modai/modules/chat/__tests__/aimock-fixtures.json b/backend/omni/src/modai/modules/chat/__tests__/aimock-fixtures.json new file mode 100644 index 0000000..eeaf3ab --- /dev/null +++ b/backend/omni/src/modai/modules/chat/__tests__/aimock-fixtures.json @@ -0,0 +1,53 @@ +{ + "fixtures": [ + { + "match": { "toolName": "calculate", "hasToolResult": false }, + "response": { + "toolCalls": [{ "name": "calculate", "arguments": { "expression": "mock_value" } }], + "usage": { "prompt_tokens": 10, "completion_tokens": 10, "input_tokens": 10, "output_tokens": 10, "total_tokens": 20 } + } + }, + { + "match": { "toolName": "nonexistent_tool", "hasToolResult": false }, + "response": { + "toolCalls": [{ "name": "nonexistent_tool", "arguments": {} }], + "usage": { "prompt_tokens": 10, "completion_tokens": 10, "input_tokens": 10, "output_tokens": 10, "total_tokens": 20 } + } + }, + { + "match": { "toolName": "broken_tool", "hasToolResult": false }, + "response": { + "toolCalls": [{ "name": "broken_tool", "arguments": {} }], + "usage": { "prompt_tokens": 10, "completion_tokens": 10, "input_tokens": 10, "output_tokens": 10, "total_tokens": 20 } + } + }, + { + "match": { "hasToolResult": true }, + "response": { + "content": "result processed", + "usage": { "prompt_tokens": 10, "completion_tokens": 10, "input_tokens": 10, "output_tokens": 10, "total_tokens": 20 } + } + }, + { + "match": { "userMessage": "world" }, + "response": { + "content": "world", + "usage": { "prompt_tokens": 10, "completion_tokens": 10, "input_tokens": 10, "output_tokens": 10, "total_tokens": 20 } + } + }, + { + "match": { "userMessage": "hello" }, + "response": { + "content": "hello", + "usage": { "prompt_tokens": 10, "completion_tokens": 10, "input_tokens": 10, "output_tokens": 10, "total_tokens": 20 } + } + }, + { + "match": {}, + "response": { + "content": "mock response", + "usage": { "prompt_tokens": 10, "completion_tokens": 10, "input_tokens": 10, "output_tokens": 10, "total_tokens": 20 } + } + } + ] +} diff --git a/backend/omni/src/modai/modules/chat/__tests__/test_chat_llm_modules.py b/backend/omni/src/modai/modules/chat/__tests__/test_chat_llm_modules.py index 042c1c4..a1e215c 100644 --- a/backend/omni/src/modai/modules/chat/__tests__/test_chat_llm_modules.py +++ b/backend/omni/src/modai/modules/chat/__tests__/test_chat_llm_modules.py @@ -4,8 +4,8 @@ a shared public interface to ensure behavioural equivalence. Tests are parametrised over: - * StrandsAgentChatModule + llmock (always runs) - * OpenAILLMChatModule + llmock (always runs) + * StrandsAgentChatModule + AIMock (always runs) + * OpenAILLMChatModule + AIMock (always runs) * StrandsAgentChatModule + OpenAI (requires UNIT_TEST_OPENAI_API_KEY) * OpenAILLMChatModule + OpenAI (requires UNIT_TEST_OPENAI_API_KEY) @@ -29,7 +29,6 @@ import httpx as httpx_lib import openai import pytest -import yaml from dotenv import find_dotenv, load_dotenv from fastapi import Request from testcontainers.core.container import DockerContainer @@ -42,31 +41,26 @@ ModelProvidersListResponse, ) -working_dir = Path.cwd() -load_dotenv(find_dotenv(str(working_dir / ".env"))) +# Use __file__ so paths resolve correctly regardless of the working directory +# (e.g. when tests are launched from the project root by VS Code). +_TEST_FILE = Path(__file__).resolve() +_BACKEND_ROOT = _TEST_FILE.parents[5] # backend/omni/ +load_dotenv(find_dotenv(str(_BACKEND_ROOT / ".env"))) # --------------------------------------------------------------------------- -# llmock container config +# AIMock container config # --------------------------------------------------------------------------- -LLMOCK_IMAGE = "ghcr.io/modai-systems/llmock:latest" -LLMOCK_PORT = 8000 -LLMOCK_API_KEY = "test-key" - -LLMOCK_CONFIG: dict[str, Any] = { - "api-key": LLMOCK_API_KEY, - "models": [ - {"id": "gpt-4o", "created": 1715367049, "owned_by": "openai"}, - ], - "strategies": ["ErrorStrategy", "ToolCallStrategy", "MirrorStrategy"], -} +AIMOCK_PORT = 4010 +AIMOCK_API_KEY = "test-key" +AIMOCK_IMAGE = "ghcr.io/copilotkit/aimock:latest" # --------------------------------------------------------------------------- # Parametrisation IDs # --------------------------------------------------------------------------- -_AGENTIC_LLMOCK = "agentic_llmock" -_NON_AGENTIC_LLMOCK = "non_agentic_llmock" +_AGENTIC_AIMOCK = "agentic_aimock" +_NON_AGENTIC_AIMOCK = "non_agentic_aimock" _AGENTIC_OPENAI = "agentic_openai" _NON_AGENTIC_OPENAI = "non_agentic_openai" @@ -76,28 +70,25 @@ ) _ALL_PARAMS = [ - _AGENTIC_LLMOCK, - _NON_AGENTIC_LLMOCK, + _AGENTIC_AIMOCK, + _NON_AGENTIC_AIMOCK, pytest.param(_AGENTIC_OPENAI, marks=_SKIP_NO_KEY), pytest.param(_NON_AGENTIC_OPENAI, marks=_SKIP_NO_KEY), ] _AGENTIC_ONLY_PARAMS = [ - _AGENTIC_LLMOCK, + _AGENTIC_AIMOCK, pytest.param(_AGENTIC_OPENAI, marks=_SKIP_NO_KEY), ] _NON_AGENTIC_ONLY_PARAMS = [ - _NON_AGENTIC_LLMOCK, + _NON_AGENTIC_AIMOCK, pytest.param(_NON_AGENTIC_OPENAI, marks=_SKIP_NO_KEY), ] -# Both module classes, llmock backend only (no OpenAI key required). -_LLMOCK_ONLY_PARAMS = [_AGENTIC_LLMOCK, _NON_AGENTIC_LLMOCK] - -# Agentic module with llmock only — for tool-call execution tests that require -# deterministic LLM behaviour (llmock ToolCallStrategy always calls the tool). -_AGENTIC_LLMOCK_ONLY_PARAMS = [_AGENTIC_LLMOCK] +# Agentic module with AIMock only — for tool-call execution tests that require +# deterministic LLM behaviour (AIMock always returns a tool call when tools are present). +_AGENTIC_AIMOCK_ONLY_PARAMS = [_AGENTIC_AIMOCK] # --------------------------------------------------------------------------- # Module factory @@ -109,7 +100,7 @@ class ModuleFactory: """Creates module instances with optional tool registry. ``model`` is the fully-qualified model string for the underlying backend - (e.g. ``"myprovider/gpt-4o"`` for llmock or ``"myopenai/gpt-4o"`` for + (e.g. ``"myprovider/gpt-4o"`` for AIMock or ``"myopenai/gpt-4o"`` for a real OpenAI provider). """ @@ -132,65 +123,64 @@ def create(self, tool_registry: Any = None) -> Any: @pytest.fixture(scope="module") -def llmock_base_url( - request: pytest.FixtureRequest, tmp_path_factory: pytest.TempPathFactory +def aimock_base_url( + request: pytest.FixtureRequest, ) -> str: - """llmock container (module-scoped).""" - config_file: Path = tmp_path_factory.mktemp("llmock") / "config.yaml" - config_file.write_text(yaml.dump(LLMOCK_CONFIG)) - os.chmod(config_file, 0o644) + """AIMock container (module-scoped). + Pulls ``ghcr.io/copilotkit/aimock:latest`` (cached by Docker) and starts a + container with the fixture file copied in from the test directory. + Returns the OpenAI-client base URL pointing at AIMock's /v1/ prefix. + """ container = ( - DockerContainer(LLMOCK_IMAGE) - .with_exposed_ports(LLMOCK_PORT) - .with_volume_mapping(str(config_file), "/app/config.yaml", "ro") + DockerContainer( + image=AIMOCK_IMAGE, + command="--fixtures /fixtures/aimock-fixtures.json --host 0.0.0.0", + ) + .with_exposed_ports(AIMOCK_PORT) + .with_copy_into_container( + _TEST_FILE.parent / "aimock-fixtures.json", + "/fixtures/aimock-fixtures.json", + ) ) container.start() host = container.get_container_host_ip() - port = container.get_exposed_port(LLMOCK_PORT) + port = container.get_exposed_port(AIMOCK_PORT) root_url = f"http://{host}:{port}" _wait_for_health(root_url) request.addfinalizer(container.stop) - return f"{root_url}/" + return f"{root_url}/v1/" @pytest.fixture(params=_ALL_PARAMS) def module_factory( - request: pytest.FixtureRequest, llmock_base_url: str + request: pytest.FixtureRequest, aimock_base_url: str ) -> ModuleFactory: - return _build_module_factory(request.param, llmock_base_url) + return _build_module_factory(request.param, aimock_base_url) @pytest.fixture(params=_AGENTIC_ONLY_PARAMS) def agentic_factory( - request: pytest.FixtureRequest, llmock_base_url: str + request: pytest.FixtureRequest, aimock_base_url: str ) -> ModuleFactory: - return _build_module_factory(request.param, llmock_base_url) + return _build_module_factory(request.param, aimock_base_url) @pytest.fixture(params=_NON_AGENTIC_ONLY_PARAMS) def non_agentic_factory( - request: pytest.FixtureRequest, llmock_base_url: str + request: pytest.FixtureRequest, aimock_base_url: str ) -> ModuleFactory: - return _build_module_factory(request.param, llmock_base_url) + return _build_module_factory(request.param, aimock_base_url) -@pytest.fixture(params=_LLMOCK_ONLY_PARAMS) -def llmock_only_factory( - request: pytest.FixtureRequest, llmock_base_url: str +@pytest.fixture(params=_AGENTIC_AIMOCK_ONLY_PARAMS) +def agentic_aimock_factory( + request: pytest.FixtureRequest, aimock_base_url: str ) -> ModuleFactory: - """All module classes, llmock backend only (no OpenAI key required).""" - return _build_module_factory(request.param, llmock_base_url) - - -@pytest.fixture(params=_AGENTIC_LLMOCK_ONLY_PARAMS) -def agentic_llmock_factory( - request: pytest.FixtureRequest, llmock_base_url: str -) -> ModuleFactory: - """Agentic module with llmock only — tool call behaviour is deterministic.""" - return _build_module_factory(request.param, llmock_base_url) + """Agentic module with AIMock only — tool call behaviour is deterministic.""" + return _build_module_factory(request.param, aimock_base_url) @pytest.fixture( @@ -247,7 +237,7 @@ async def test_response_is_completed(self, module_factory: ModuleFactory): async def test_response_contains_expected_text(self, module_factory: ModuleFactory): """Response text contains the requested word. - llmock MirrorStrategy echoes the entire user message (which contains + AIMock echoes the entire user message (which contains the word 'hello'); real LLMs respond with the requested word. """ module = module_factory.create() @@ -259,11 +249,19 @@ async def test_response_contains_expected_text(self, module_factory: ModuleFacto assert "hello" in result.output[0].content[0].text.lower() @pytest.mark.asyncio - async def test_response_reports_token_usage(self, module_factory: ModuleFactory): - module = module_factory.create() + async def test_response_reports_token_usage( + self, non_agentic_factory: ModuleFactory + ): + """Token usage is verified via the Responses API (non-agentic module). + + The agentic module uses streaming Chat Completions internally; token + counts there depend on the LLM returning usage in streaming chunks, + which is not guaranteed by mock backends. + """ + module = non_agentic_factory.create() result = await module.generate_response( _make_request(), - {"model": module_factory.model, "input": "Hi"}, + {"model": non_agentic_factory.model, "input": "Hi"}, ) assert result.usage.input_tokens > 0 assert result.usage.output_tokens > 0 @@ -311,7 +309,7 @@ async def test_multi_turn_uses_previous_context( """Second turn with conversation history produces the expected response. First turn asks to echo 'hello'; second turn (with history) asks to echo - 'world'. llmock MirrorStrategy echoes the last user message (which + 'world'. AIMock echoes the last user message (which contains 'world'); real LLMs follow the instruction. """ module = module_factory.create() @@ -366,7 +364,7 @@ async def test_stream_text_contains_expected_word( ): """Assembled stream text contains the requested word. - llmock MirrorStrategy echoes the entire user message (which contains + AIMock echoes the entire user message (which contains the word 'hello'); real LLMs respond with the requested word. """ module = module_factory.create() @@ -435,7 +433,7 @@ async def test_multi_turn_streaming_uses_previous_context( """Multi-turn: first turn non-streaming, second turn streaming with history. Second turn asks to echo 'world'; assembled delta text must contain 'world'. - llmock MirrorStrategy echoes the last user message (which contains 'world'); + AIMock echoes the last user message (which contains 'world'); real LLMs follow the instruction. """ module = module_factory.create() @@ -489,6 +487,77 @@ async def test_multi_turn_streaming_uses_previous_context( } +# =================================================================== +# Tool forwarding (both modules) +# =================================================================== + + +class TestToolForwarding: + """Tools specified in the request are forwarded to the LLM. + + Verified via observable behaviour: + - When tools are absent the response is plain text with no function calls. + - When tools are present the LLM returns a function call (non-agentic) or + the registry is invoked (agentic). + """ + + @pytest.mark.asyncio + async def test_without_tools_non_agentic_returns_text( + self, non_agentic_factory: ModuleFactory + ): + """No tools in the request → output contains text, not a function call.""" + module = non_agentic_factory.create() + result = await module.generate_response( + _make_request(), + {"model": non_agentic_factory.model, "input": "Hi"}, + ) + function_calls = [ + item for item in result.output if item.type == "function_call" + ] + assert len(function_calls) == 0 + text_items = [item for item in result.output if item.type == "message"] + assert len(text_items) >= 1 + + @pytest.mark.asyncio + async def test_without_tools_agentic_does_not_invoke_registry( + self, agentic_aimock_factory: ModuleFactory + ): + """No tools in the request → the tool registry is never called.""" + registry = Mock() + registry.run_tool = AsyncMock() + module = agentic_aimock_factory.create(tool_registry=registry) + result = await module.generate_response( + _make_request(), + {"model": agentic_aimock_factory.model, "input": "Hi"}, + ) + assert result.status == "completed" + registry.run_tool.assert_not_called() + + @pytest.mark.asyncio + async def test_tool_name_forwarded_to_registry( + self, agentic_aimock_factory: ModuleFactory + ): + """The registry is called with the exact tool name from the request.""" + captured: list[dict] = [] + + async def _capture(request: Any, params: dict[str, Any]) -> str: + captured.append(dict(params)) + return "result" + + registry = Mock() + registry.run_tool = AsyncMock(side_effect=_capture) + module = agentic_aimock_factory.create(tool_registry=registry) + await module.generate_response( + _make_request(), + { + "model": agentic_aimock_factory.model, + "input": "call the calculate tool", + "tools": [_AGENTIC_CALCULATE_TOOL], + }, + ) + assert any(c.get("name") == "calculate" for c in captured) + + class TestRawToolCalling: """Tool calls are returned in the response output without being executed. @@ -581,7 +650,7 @@ class TestAgenticLoop: @pytest.mark.asyncio async def test_tool_is_executed_during_non_streaming_loop( - self, agentic_llmock_factory: ModuleFactory + self, agentic_aimock_factory: ModuleFactory ): captured_calls: list[dict] = [] @@ -592,11 +661,11 @@ async def _run_tool(request: Any, params: dict[str, Any]) -> str: registry = Mock() registry.run_tool = AsyncMock(side_effect=_run_tool) - module = agentic_llmock_factory.create(tool_registry=registry) + module = agentic_aimock_factory.create(tool_registry=registry) result = await module.generate_response( _make_request(), { - "model": agentic_llmock_factory.model, + "model": agentic_aimock_factory.model, "input": "call tool 'calculate' with '{\"expression\": \"6*7\"}'", "tools": [_AGENTIC_CALCULATE_TOOL], }, @@ -607,7 +676,7 @@ async def _run_tool(request: Any, params: dict[str, Any]) -> str: @pytest.mark.asyncio async def test_tool_is_executed_during_streaming_loop( - self, agentic_llmock_factory: ModuleFactory + self, agentic_aimock_factory: ModuleFactory ): captured_calls: list[dict] = [] @@ -618,11 +687,11 @@ async def _run_tool(request: Any, params: dict[str, Any]) -> str: registry = Mock() registry.run_tool = AsyncMock(side_effect=_run_tool) - module = agentic_llmock_factory.create(tool_registry=registry) + module = agentic_aimock_factory.create(tool_registry=registry) gen = await module.generate_response( _make_request(), { - "model": agentic_llmock_factory.model, + "model": agentic_aimock_factory.model, "input": "call tool 'calculate' with '{\"expression\": \"6*7\"}'", "tools": [_AGENTIC_CALCULATE_TOOL], "stream": True, @@ -633,6 +702,85 @@ async def _run_tool(request: Any, params: dict[str, Any]) -> str: assert events[-1].type == "response.completed" assert len(captured_calls) >= 1 + @pytest.mark.asyncio + async def test_full_loop_non_streaming_ends_with_text( + self, agentic_aimock_factory: ModuleFactory + ): + """Full non-streaming agentic loop: LLM requests tool → Strands executes it + → result is fed back → LLM produces a final text message. + + AIMock deterministically requests a tool call on the first turn (tools + present, no prior tool result) and returns plain text on the second turn + (tool result present). This verifies that ``StrandsAgentChatModule`` + completes the full loop and the final response contains a text message, + not another function call. + """ + captured_calls: list[dict] = [] + + async def _run_tool(request: Any, params: dict[str, Any]) -> str: + captured_calls.append(dict(params)) + return "42" + + registry = Mock() + registry.run_tool = AsyncMock(side_effect=_run_tool) + + module = agentic_aimock_factory.create(tool_registry=registry) + result = await module.generate_response( + _make_request(), + { + "model": agentic_aimock_factory.model, + "input": "call tool 'calculate'", + "tools": [_AGENTIC_CALCULATE_TOOL], + }, + ) + + assert result.status == "completed" + assert len(captured_calls) >= 1 + assert any(c.get("name") == "calculate" for c in captured_calls) + text_messages = [item for item in result.output if item.type == "message"] + assert len(text_messages) >= 1 + + @pytest.mark.asyncio + async def test_full_loop_streaming_ends_with_text_events( + self, agentic_aimock_factory: ModuleFactory + ): + """Full streaming agentic loop: LLM requests tool → Strands executes it + → result is fed back → LLM streams a final text response. + + Verifies that after the tool is executed the stream contains at least one + ``response.output_text.delta`` event before the final ``response.completed``. + """ + captured_calls: list[dict] = [] + + async def _run_tool(request: Any, params: dict[str, Any]) -> str: + captured_calls.append(dict(params)) + return "42" + + registry = Mock() + registry.run_tool = AsyncMock(side_effect=_run_tool) + + module = agentic_aimock_factory.create(tool_registry=registry) + gen = await module.generate_response( + _make_request(), + { + "model": agentic_aimock_factory.model, + "input": "call tool 'calculate'", + "tools": [_AGENTIC_CALCULATE_TOOL], + "stream": True, + }, + ) + events = [e async for e in gen] + + assert events[-1].type == "response.completed" + assert len(captured_calls) >= 1 + assert any(c.get("name") == "calculate" for c in captured_calls) + text_delta_events = [ + e + for e in events + if getattr(e, "type", None) == "response.output_text.delta" + ] + assert len(text_delta_events) >= 1 + # =================================================================== # 5) Model and provider errors (all module/backend combinations) @@ -710,34 +858,77 @@ async def test_provider_module_raises_propagates(self, module_class: type): class TestLLMErrors: """Errors during the actual LLM call. - Error trigger tests use llmock's ErrorStrategy; connection error tests use - an unreachable server. Both streaming and non-streaming paths are covered. + HTTP error tests use pytest-httpserver to return 4xx/5xx responses. + Connection error tests use an unreachable server (broken_module fixture). + Both streaming and non-streaming paths are covered. """ @pytest.mark.asyncio - async def test_non_streaming_error_429(self, llmock_only_factory: ModuleFactory): - """llmock ErrorStrategy returns 429 when message matches trigger phrase.""" - module = llmock_only_factory.create() + @pytest.mark.parametrize( + "module_class", + [ + pytest.param(StrandsAgentChatModule, id="agentic"), + pytest.param(OpenAILLMChatModule, id="non_agentic"), + ], + ) + async def test_non_streaming_error_429(self, module_class: type, httpserver: Any): + """HTTP 429 response from the LLM raises an exception.""" + httpserver.expect_request("/chat/completions", method="POST").respond_with_data( + '{"error":{"message":"Rate limit exceeded","type":"requests","code":429}}', + status=429, + content_type="application/json", + ) + httpserver.expect_request("/responses", method="POST").respond_with_data( + '{"error":{"message":"Rate limit exceeded","type":"requests","code":429}}', + status=429, + content_type="application/json", + ) + module = module_class( + dependencies=_make_dependencies( + provider_module=_make_provider_module( + [_make_provider(base_url=httpserver.url_for("/"), api_key="key")] + ) + ), + config={}, + ) with pytest.raises(Exception): await module.generate_response( _make_request(), - { - "model": llmock_only_factory.model, - "input": 'raise error {"code": 429, "message": "Rate limit exceeded"}', - }, + {"model": "myprovider/gpt-4o", "input": "Hi"}, ) @pytest.mark.asyncio - async def test_non_streaming_error_500(self, llmock_only_factory: ModuleFactory): - """llmock ErrorStrategy returns 500 when message matches trigger phrase.""" - module = llmock_only_factory.create() + @pytest.mark.parametrize( + "module_class", + [ + pytest.param(StrandsAgentChatModule, id="agentic"), + pytest.param(OpenAILLMChatModule, id="non_agentic"), + ], + ) + async def test_non_streaming_error_500(self, module_class: type, httpserver: Any): + """HTTP 500 response from the LLM raises an exception.""" + httpserver.expect_request("/chat/completions", method="POST").respond_with_data( + '{"error":{"message":"Internal server error","type":"api_error","code":500}}', + status=500, + content_type="application/json", + ) + httpserver.expect_request("/responses", method="POST").respond_with_data( + '{"error":{"message":"Internal server error","type":"api_error","code":500}}', + status=500, + content_type="application/json", + ) + module = module_class( + dependencies=_make_dependencies( + provider_module=_make_provider_module( + [_make_provider(base_url=httpserver.url_for("/"), api_key="key")] + ) + ), + config={}, + ) with pytest.raises(Exception): await module.generate_response( _make_request(), - { - "model": llmock_only_factory.model, - "input": 'raise error {"code": 500, "message": "Internal server error"}', - }, + {"model": "myprovider/gpt-4o", "input": "Hi"}, ) @pytest.mark.asyncio @@ -750,16 +941,36 @@ async def test_non_streaming_connection_error(self, broken_module: Any): ) @pytest.mark.asyncio - async def test_streaming_error_trigger(self, llmock_only_factory: ModuleFactory): - """Error during streaming when llmock ErrorStrategy is triggered.""" - module = llmock_only_factory.create() + @pytest.mark.parametrize( + "module_class", + [ + pytest.param(StrandsAgentChatModule, id="agentic"), + pytest.param(OpenAILLMChatModule, id="non_agentic"), + ], + ) + async def test_streaming_error(self, module_class: type, httpserver: Any): + """HTTP 500 from the LLM propagates as an exception during streaming.""" + httpserver.expect_request("/chat/completions", method="POST").respond_with_data( + '{"error":{"message":"Internal server error","type":"api_error","code":500}}', + status=500, + content_type="application/json", + ) + httpserver.expect_request("/responses", method="POST").respond_with_data( + '{"error":{"message":"Internal server error","type":"api_error","code":500}}', + status=500, + content_type="application/json", + ) + module = module_class( + dependencies=_make_dependencies( + provider_module=_make_provider_module( + [_make_provider(base_url=httpserver.url_for("/"), api_key="key")] + ) + ), + config={}, + ) gen = await module.generate_response( _make_request(), - { - "model": llmock_only_factory.model, - "input": 'raise error {"code": 500, "message": "Internal server error"}', - "stream": True, - }, + {"model": "myprovider/gpt-4o", "input": "Hi", "stream": True}, ) with pytest.raises(Exception): async for _ in gen: @@ -934,222 +1145,25 @@ async def _run_tool_partial(request: Any, params: dict[str, Any]) -> Any: assert isinstance(result, openai.types.responses.Response) -# =================================================================== -# 8) Tool spec pass-through (StrandsAgentChatModule only) -# =================================================================== - - -class TestToolSpecPassThrough: - """The tool spec from the client request is forwarded to the LLM unchanged. - - Only ``StrandsAgentChatModule`` assembles a tool spec for the Strands - agent loop. The ``description`` and ``parameters`` provided by the - client in the request ``tools`` array MUST reach the LLM verbatim; the - registry definition is only used for execution (``run``), not for - defining the tool's interface that the LLM sees. - - Uses the llmock ``/history`` endpoint to inspect the exact request body - that was forwarded to the LLM, without any additional HTTP mocking. - """ - - @pytest.fixture(autouse=True) - def clear_history(self, llmock_base_url: str) -> None: - httpx_lib.delete(f"{llmock_base_url}history") - - @pytest.mark.asyncio - async def test_client_description_forwarded_to_llm(self, llmock_base_url: str): - """Tool description from client spec reaches LLM instead of registry's.""" - - registry = Mock() - registry.run_tool = AsyncMock(return_value="42") - - provider = _make_provider(base_url=llmock_base_url, api_key=LLMOCK_API_KEY) - module = StrandsAgentChatModule( - dependencies=_make_dependencies( - provider_module=_make_provider_module([provider]), - tool_registry=registry, - ), - config={}, - ) - - result = await module.generate_response( - _make_request(), - { - "model": "myprovider/gpt-4o", - "input": "Hi", - "tools": [ - { - "type": "function", - "name": "calculate", - "description": "CLIENT description — must reach LLM unchanged", - "parameters": { - "type": "object", - "properties": {"expression": {"type": "string"}}, - "required": ["expression"], - }, - } - ], - }, - ) - - assert result.status == "completed" - history = httpx_lib.get(f"{llmock_base_url}history").json()["requests"] - chat_requests = [r for r in history if r["path"] == "/chat/completions"] - assert chat_requests[0]["body"]["tools"] == [ - { - "type": "function", - "function": { - "name": "calculate", - "description": "CLIENT description — must reach LLM unchanged", - "parameters": { - "type": "object", - "properties": { - "expression": { - "type": "string", - # Strands adds a default description for properties - # that don't have one ("Property {name}"). - "description": "Property expression", - } - }, - "required": ["expression"], - }, - }, - } - ] - - @pytest.mark.asyncio - async def test_client_parameters_forwarded_to_llm(self, llmock_base_url: str): - """Tool parameters schema from client spec reaches LLM instead of registry's.""" - - registry = Mock() - registry.run_tool = AsyncMock(return_value="42") - - provider = _make_provider(base_url=llmock_base_url, api_key=LLMOCK_API_KEY) - module = StrandsAgentChatModule( - dependencies=_make_dependencies( - provider_module=_make_provider_module([provider]), - tool_registry=registry, - ), - config={}, - ) - - result = await module.generate_response( - _make_request(), - { - "model": "myprovider/gpt-4o", - "input": "Hi", - "tools": [ - { - "type": "function", - "name": "calculate", - "description": "Do math", - "parameters": { - "type": "object", - "properties": { - "expression": { - "type": "string", - "description": "Math expr", - } - }, - "required": ["expression"], - }, - } - ], - }, - ) - - assert result.status == "completed" - history = httpx_lib.get(f"{llmock_base_url}history").json()["requests"] - chat_requests = [r for r in history if r["path"] == "/chat/completions"] - assert chat_requests[0]["body"]["tools"] == [ - { - "type": "function", - "function": { - "name": "calculate", - "description": "Do math", - "parameters": { - "type": "object", - "properties": { - "expression": { - "type": "string", - "description": "Math expr", - } - }, - "required": ["expression"], - }, - }, - } - ] - - @pytest.mark.asyncio - async def test_client_only_name_sends_empty_description_to_llm( - self, llmock_base_url: str - ): - """When client sends only a tool name (no description/parameters), the - LLM receives a tool with empty description — the registry is never - consulted for spec information.""" - - registry = Mock() - registry.run_tool = AsyncMock(return_value="42") - - provider = _make_provider(base_url=llmock_base_url, api_key=LLMOCK_API_KEY) - module = StrandsAgentChatModule( - dependencies=_make_dependencies( - provider_module=_make_provider_module([provider]), - tool_registry=registry, - ), - config={}, - ) - - result = await module.generate_response( - _make_request(), - { - "model": "myprovider/gpt-4o", - "input": "Hi", - # Client sends only the tool name — no description or parameters - "tools": [{"type": "function", "name": "calculate"}], - }, - ) - - assert result.status == "completed" - history = httpx_lib.get(f"{llmock_base_url}history").json()["requests"] - chat_requests = [r for r in history if r["path"] == "/chat/completions"] - assert chat_requests[0]["body"]["tools"] == [ - { - "type": "function", - "function": { - "name": "calculate", - "description": "", - "parameters": { - "type": "object", - "properties": {}, - # Strands adds "required": [] when the field is absent. - "required": [], - }, - }, - } - ] - - # --------------------------------------------------------------------------- # Shared helpers # --------------------------------------------------------------------------- -def _build_module_factory(param_id: str, llmock_base_url: str) -> ModuleFactory: - if param_id == _AGENTIC_LLMOCK: +def _build_module_factory(param_id: str, aimock_base_url: str) -> ModuleFactory: + if param_id == _AGENTIC_AIMOCK: return ModuleFactory( module_class=StrandsAgentChatModule, provider_module=_make_provider_module( - [_make_provider(base_url=llmock_base_url, api_key=LLMOCK_API_KEY)] + [_make_provider(base_url=aimock_base_url, api_key=AIMOCK_API_KEY)] ), model="myprovider/gpt-4o", ) - if param_id == _NON_AGENTIC_LLMOCK: + if param_id == _NON_AGENTIC_AIMOCK: return ModuleFactory( module_class=OpenAILLMChatModule, provider_module=_make_provider_module( - [_make_provider(base_url=llmock_base_url, api_key=LLMOCK_API_KEY)] + [_make_provider(base_url=aimock_base_url, api_key=AIMOCK_API_KEY)] ), model="myprovider/gpt-4o", ) @@ -1238,7 +1252,7 @@ def _real_model() -> str: def _wait_for_health(base_url: str, timeout: float = 30.0) -> None: - """Poll the llmock health endpoint until it responds.""" + """Poll the mock server health endpoint until it responds.""" deadline = time.time() + timeout while time.time() < deadline: try: @@ -1249,5 +1263,5 @@ def _wait_for_health(base_url: str, timeout: float = 30.0) -> None: pass time.sleep(0.5) raise TimeoutError( - f"llmock health check at {base_url}/health did not respond within {timeout}s" + f"Mock server health check at {base_url}/health did not respond within {timeout}s" ) diff --git a/backend/omni/uv.lock b/backend/omni/uv.lock index fee9223..3bde2fc 100644 --- a/backend/omni/uv.lock +++ b/backend/omni/uv.lock @@ -965,7 +965,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-httpserver" }, { name = "ruff" }, - { name = "testcontainers" }, + { name = "testcontainers", specifier = ">=4.15.0rc2" }, ] [[package]] @@ -1914,7 +1914,7 @@ wheels = [ [[package]] name = "testcontainers" -version = "4.14.2" +version = "4.15.0rc2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, @@ -1923,9 +1923,9 @@ dependencies = [ { name = "urllib3" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ca/ac/a597c3a0e02b26cbed6dd07df68be1e57684766fd1c381dee9b170a99690/testcontainers-4.14.2.tar.gz", hash = "sha256:1340ccf16fe3acd9389a6c9e1d9ab21d9fe99a8afdf8165f89c3e69c1967d239", size = 166841, upload-time = "2026-03-18T05:19:16.696Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/3c/775a4635a517ab2e4f1c36adc4d43f6a80146040a9b104dc40c1f4f7b635/testcontainers-4.15.0rc2.tar.gz", hash = "sha256:4764016e73da0fa960eb8360687d22710cd68bcc01a4d03189fbe1da896a805d", size = 185257, upload-time = "2026-04-30T00:47:57.244Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/2d/26b8b30067d94339afee62c3edc9b803a6eb9332f521ba77d8aaab5de873/testcontainers-4.14.2-py3-none-any.whl", hash = "sha256:0d0522c3cd8f8d9627cda41f7a6b51b639fa57bdc492923c045117933c668d68", size = 125712, upload-time = "2026-03-18T05:19:15.29Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/5833ae272ae79dceeea58b6c7381c47cbcbd0113d0d0b04da8ae1ac45e48/testcontainers-4.15.0rc2-py3-none-any.whl", hash = "sha256:e55b9045842c5bdfdd295e0d0b09aeafb3c1fb9d6f30bd8e718df8fd48dcdc41", size = 138103, upload-time = "2026-04-30T00:47:55.514Z" }, ] [[package]] diff --git a/e2e_tests/tests_omni_full/justfile b/e2e_tests/tests_omni_full/justfile index b1c5199..1195bc5 100644 --- a/e2e_tests/tests_omni_full/justfile +++ b/e2e_tests/tests_omni_full/justfile @@ -6,6 +6,9 @@ default: # Install dependencies install: pnpm install + +# Install additional playwright dependencies +install-playwright: pnpm exec playwright install --with-deps # Run e2e tests diff --git a/e2e_tests/tests_omni_full/playwright.config.ts b/e2e_tests/tests_omni_full/playwright.config.ts index d371c0a..2d39c2f 100644 --- a/e2e_tests/tests_omni_full/playwright.config.ts +++ b/e2e_tests/tests_omni_full/playwright.config.ts @@ -63,13 +63,12 @@ export default defineConfig({ timeout: 120_000, }, { - name: "LLMock", - command: - "docker container run --rm -p 3001:8000 -e LLMOCK_DEBUG=true ghcr.io/modai-systems/llmock:latest", - url: "http://localhost:3001/health", + name: "AIMock", + command: "bash scripts/start-aimock.sh", + url: "http://localhost:4010/health", reuseExistingServer: !process.env.CI, - gracefulShutdown: { signal: "SIGTERM", timeout: 5_000 }, - timeout: 30_000, + gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }, + timeout: 120_000, }, { name: "Dice Roller", diff --git a/e2e_tests/tests_omni_full/scripts/start-aimock.sh b/e2e_tests/tests_omni_full/scripts/start-aimock.sh new file mode 100755 index 0000000..2d4469f --- /dev/null +++ b/e2e_tests/tests_omni_full/scripts/start-aimock.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Starts AIMock for e2e tests using the fixture file from tests_omni_full/src/. +# +# Lifecycle managed by Playwright's webServer config: +# - url: http://localhost:4010/health + +set -euo pipefail + +SCRIPT_DIR=$(dirname "$(realpath "$0")") +FIXTURES_FILE="$SCRIPT_DIR/../src/aimock-fixtures.json" + +docker container run --rm --pull always \ + -p 4010:4010 \ + -v "$FIXTURES_FILE:/fixtures/aimock-fixtures.json:ro" \ + ghcr.io/copilotkit/aimock:latest \ + --fixtures /fixtures/aimock-fixtures.json --host 0.0.0.0 diff --git a/e2e_tests/tests_omni_full/src/aimock-fixtures.json b/e2e_tests/tests_omni_full/src/aimock-fixtures.json new file mode 100644 index 0000000..5a74435 --- /dev/null +++ b/e2e_tests/tests_omni_full/src/aimock-fixtures.json @@ -0,0 +1,24 @@ +{ + "fixtures": [ + { + "match": { "userMessage": "Hi" }, + "response": { "content": "Hi" } + }, + { + "match": { "userMessage": "Hello again" }, + "response": { "content": "Hello again" } + }, + { + "match": { "toolName": "roll_dice", "hasToolResult": false }, + "response": { "toolCalls": [{ "name": "roll_dice", "arguments": {} }] } + }, + { + "match": { "hasToolResult": true }, + "response": { "content": "last tool call result is tool output" } + }, + { + "match": {}, + "response": { "content": "mock response" } + } + ] +} diff --git a/e2e_tests/tests_omni_full/src/chat.spec.ts b/e2e_tests/tests_omni_full/src/chat.spec.ts index fd30d07..7a4177d 100644 --- a/e2e_tests/tests_omni_full/src/chat.spec.ts +++ b/e2e_tests/tests_omni_full/src/chat.spec.ts @@ -1,28 +1,22 @@ -import { expect, test } from "@playwright/test"; +import { test } from "@playwright/test"; import { TEST_USER_PASSWORD, TEST_USERNAME } from "./fixtures"; import { ChatPage, LLMProvidersPage, NanoIdpLoginPage } from "./pages"; const BACKEND_URL = "http://localhost:8000"; -const LLMOCK_URL = "http://localhost:3001"; +const AIMOCK_URL = "http://localhost:4010/v1"; test.describe("Chat", () => { test.beforeEach(async ({ page }) => { // Reset backend state before each test await page.request.post(`${BACKEND_URL}/api/reset/full`); - // Reset llmock history before each test - await page.request.delete(`${LLMOCK_URL}/history`); // Login via NanoIDP (navigates to / which triggers backend OIDC flow) const loginPage = new NanoIdpLoginPage(page); await loginPage.login(TEST_USERNAME, TEST_USER_PASSWORD); - // Set up mock LLM provider via localStorage + // Set up mock LLM provider via backend API const providerPage = new LLMProvidersPage(page); - await providerPage.addProvider( - "Mock Provider", - "http://localhost:3001", - "", - ); + await providerPage.addProvider("Mock Provider", AIMOCK_URL, ""); await page .getByText("How can I help you today?") .waitFor({ state: "visible", timeout: 20000 }); @@ -32,7 +26,7 @@ test.describe("Chat", () => { const chatPage = new ChatPage(page); await chatPage.navigateTo(); - await chatPage.selectFirstModel(); + await chatPage.selectModel("gpt-4o"); await chatPage.sendMessage("Hi"); await chatPage.assertLastResponse("Hi"); @@ -42,7 +36,7 @@ test.describe("Chat", () => { const chatPage = new ChatPage(page); await chatPage.navigateTo(); - await chatPage.selectFirstModel(); + await chatPage.selectModel("gpt-4o"); await chatPage.sendMessage("Hi"); await chatPage.assertLastResponse("Hi"); @@ -54,61 +48,14 @@ test.describe("Chat", () => { test("should call dice-roller tool and return result", async ({ page }) => { const chatPage = new ChatPage(page); await chatPage.navigateTo(); - await chatPage.selectFirstModel(); + await chatPage.selectModel("gpt-4o"); await chatPage.enableTool("Roll Dice"); - // llmock trigger: "call tool '' with ''" causes it to return - // a tool_call response. The backend Strands agent then calls the - // dice-roller microservice (port 8001) and sends the result back. - // LLMock responds with "last tool call result is " after the agent - // sends the tool result back. - await chatPage.sendMessage( - "call tool 'roll_dice' with '{\"count\": 1, \"sides\": 6}'", - ); + // AIMock: when tools are present and no prior tool result it returns + // a function_call for the first tool. The backend Strands agent executes + // roll_dice and sends the result back. AIMock then responds with + // "last tool call result is {json}" which is what we assert. + await chatPage.sendMessage("roll the dice"); await chatPage.assertLastResponse("last tool call result is", 20000); - - // Verify the full tool definition was sent to the LLM. - const historyResponse = await page.request.get(`${LLMOCK_URL}/history`); - const history = await historyResponse.json(); - const toolCallRequest = history.requests.find( - (r: { path: string; body: { tools?: unknown[] } }) => - r.path === "/chat/completions" && - Array.isArray(r.body?.tools) && - r.body.tools.length > 0, - ); - expect(toolCallRequest).toBeDefined(); - expect(toolCallRequest.body.tools).toEqual([ - { - type: "function", - function: { - name: "roll_dice", - description: "Roll dice and return the results", - parameters: { - type: "object", - title: "DiceRequest", - properties: { - count: { - type: "integer", - title: "Count", - description: "Number of dice to roll", - default: 1, - minimum: 1.0, - maximum: 100.0, - }, - sides: { - type: "integer", - title: "Sides", - description: "Number of sides per die", - default: 6, - minimum: 2.0, - maximum: 100.0, - }, - }, - // Strands adds "required": [] when the field is absent in the schema. - required: [], - }, - }, - }, - ]); }); }); diff --git a/e2e_tests/tests_omni_full/src/pages.ts b/e2e_tests/tests_omni_full/src/pages.ts index e2403bc..f5cfd3c 100644 --- a/e2e_tests/tests_omni_full/src/pages.ts +++ b/e2e_tests/tests_omni_full/src/pages.ts @@ -55,10 +55,8 @@ export class LLMProvidersPage { } async navigateTo() { - // Click the Providers nav button in the header - await this.page - .getByRole("button", { name: "Providers", exact: true }) - .click(); + const sidebar = new Sidebar(this.page); + await sidebar.openGlobalSettingsSubMenu("Providers"); } } @@ -118,7 +116,7 @@ export class ChatPage { async navigateTo(): Promise { const sidebar = new Sidebar(this.page); - await sidebar.navigateTo("New Chat"); + await sidebar.openChatSubMenu("New"); await expect(this.page).toHaveURL(/\/chat\/[\w-]+/); } @@ -148,20 +146,16 @@ export class ChatPage { await toolsButton.click(); } - async selectFirstModel(): Promise { - // Open the model selector popover and click the first option - const modelButton = this.page - .locator("button") - .filter({ - hasText: /Select model|gpt-/i, - }) - .first(); + async selectModel(modelName: string): Promise { + const modelButton = this.page.getByTestId("model-selector-button"); await expect(modelButton).toBeEnabled({ timeout: 10000 }); await modelButton.click(); - // Click the first option in the popover/command list - const firstOption = this.page.locator('[role="option"]').first(); - await firstOption.waitFor({ state: "visible", timeout: 5000 }); - await firstOption.click(); + const option = this.page + .locator('[role="option"]') + .filter({ hasText: modelName }) + .first(); + await option.waitFor({ state: "visible", timeout: 5000 }); + await option.click(); } async sendMessage(message: string): Promise { @@ -229,6 +223,51 @@ export class Sidebar { } } + async openChatSection(): Promise { + await this.open(); + // Expand the Chat collapsible if not already open. + const newButton = this.page.locator( + '[data-sidebar="menu-sub-button"]', + { hasText: "New" }, + ); + const isAlreadyOpen = await newButton.isVisible(); + if (!isAlreadyOpen) { + await this.page + .getByRole("button", { name: "Chat", exact: true }) + .click(); + } + } + + async openChatSubMenu(subItem: string): Promise { + await this.openChatSection(); + await this.page + .locator('[data-sidebar="menu-sub-button"]', { hasText: subItem }) + .click(); + } + + async openGlobalSettingsSection(): Promise { + await this.open(); + // Global Settings uses shadcn Collapsible.Root (bind:open), so the + // Providers sub-button is only visible when the section is expanded. + const providersButton = this.page.locator( + '[data-sidebar="menu-sub-button"]', + { hasText: "Providers" }, + ); + const isAlreadyOpen = await providersButton.isVisible(); + if (!isAlreadyOpen) { + await this.page + .getByRole("button", { name: "Global Settings", exact: true }) + .click(); + } + } + + async openGlobalSettingsSubMenu(subItem: string): Promise { + await this.openGlobalSettingsSection(); + await this.page + .locator('[data-sidebar="menu-sub-button"]', { hasText: subItem }) + .click(); + } + async logout(): Promise { await this.open(); await this.page diff --git a/e2e_tests/tests_omni_light/justfile b/e2e_tests/tests_omni_light/justfile index 92bbd0d..0e6e6ec 100644 --- a/e2e_tests/tests_omni_light/justfile +++ b/e2e_tests/tests_omni_light/justfile @@ -6,6 +6,9 @@ default: # Install dependencies install: pnpm install + +# Install additional playwright dependencies +install-playwright: pnpm exec playwright install --with-deps # Run e2e tests diff --git a/e2e_tests/tests_omni_light/playwright.config.ts b/e2e_tests/tests_omni_light/playwright.config.ts index 010d6e0..93037a9 100644 --- a/e2e_tests/tests_omni_light/playwright.config.ts +++ b/e2e_tests/tests_omni_light/playwright.config.ts @@ -48,13 +48,12 @@ export default defineConfig({ timeout: 120_000, }, { - name: "LLMock", - command: - "docker container run --rm --platform linux/amd64 -p 3001:8000 -e LLMOCK_CORS_ALLOW_ORIGINS='[\"http://localhost:4173\"]' ghcr.io/modai-systems/llmock:latest", - url: "http://localhost:3001/health", + name: "AIMock", + command: "bash scripts/start-aimock.sh", + url: "http://localhost:4010/health", reuseExistingServer: !process.env.CI, - gracefulShutdown: { signal: "SIGTERM", timeout: 5000 }, - timeout: 30_000, + gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }, + timeout: 120_000, }, ], }); diff --git a/e2e_tests/tests_omni_light/scripts/start-aimock.sh b/e2e_tests/tests_omni_light/scripts/start-aimock.sh new file mode 100755 index 0000000..89fc156 --- /dev/null +++ b/e2e_tests/tests_omni_light/scripts/start-aimock.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Starts AIMock for e2e tests using the fixture file from tests_omni_light/src/. +# +# Lifecycle managed by Playwright's webServer config: +# - url: http://localhost:4010/health + +set -euo pipefail + +SCRIPT_DIR=$(dirname "$(realpath "$0")") +FIXTURES_FILE="$SCRIPT_DIR/../src/aimock-fixtures.json" + +docker container run --rm --pull always \ + -p 4010:4010 \ + -v "$FIXTURES_FILE:/fixtures/aimock-fixtures.json:ro" \ + ghcr.io/copilotkit/aimock:latest \ + --fixtures /fixtures/aimock-fixtures.json --host 0.0.0.0 diff --git a/e2e_tests/tests_omni_light/src/aimock-fixtures.json b/e2e_tests/tests_omni_light/src/aimock-fixtures.json new file mode 100644 index 0000000..713dd24 --- /dev/null +++ b/e2e_tests/tests_omni_light/src/aimock-fixtures.json @@ -0,0 +1,24 @@ +{ + "fixtures": [ + { + "match": { "userMessage": "first message" }, + "response": { "content": "first message" } + }, + { + "match": { "userMessage": "second message" }, + "response": { "content": "second message" } + }, + { + "match": { "userMessage": "world" }, + "response": { "content": "world" } + }, + { + "match": { "userMessage": "hello" }, + "response": { "content": "hello" } + }, + { + "match": {}, + "response": { "content": "mock response" } + } + ] +} diff --git a/e2e_tests/tests_omni_light/src/chat.spec.ts b/e2e_tests/tests_omni_light/src/chat.spec.ts index 4c23b78..ae0b979 100644 --- a/e2e_tests/tests_omni_light/src/chat.spec.ts +++ b/e2e_tests/tests_omni_light/src/chat.spec.ts @@ -1,26 +1,28 @@ import { test } from "@playwright/test"; import { ChatPage } from "./pages"; +const AIMOCK_PORT = 4010; + test.describe("Chat", () => { test.beforeEach(async ({ page }) => { await page.goto("/chat"); await page.evaluate(() => { localStorage.clear(); }); - await page.evaluate(() => { + await page.evaluate((port) => { localStorage.setItem( "llm_providers", JSON.stringify([ { - id: "llmmock", - name: "LLMMock", + id: "aimock", + name: "AIMock", type: "openai", - base_url: "http://localhost:3001", + base_url: `http://localhost:${port}/v1`, api_key: "", }, ]), ); - }); + }, AIMOCK_PORT); await page.goto("/chat"); await page .getByText("How can I help you today?") @@ -30,7 +32,7 @@ test.describe("Chat", () => { test("Chat responds", async ({ page }) => { const chatPage = new ChatPage(page); await chatPage.navigateTo(); - await chatPage.selectFirstModel(); + await chatPage.selectModel("gpt-4o"); await chatPage.sendMessage("hello"); await chatPage.assertLastResponse("hello"); @@ -40,7 +42,7 @@ test.describe("Chat", () => { const chatPage = new ChatPage(page); await chatPage.navigateTo(); - await chatPage.selectFirstModel(); + await chatPage.selectModel("gpt-4o"); await chatPage.sendMessage("hello"); await chatPage.assertLastResponse("hello"); @@ -54,10 +56,10 @@ test.describe("Chat", () => { }) => { const chatPage = new ChatPage(page); await chatPage.navigateTo(); - await chatPage.selectFirstModel(); + await chatPage.selectModel("gpt-4o"); // Send a message that will be echoed back as a wide markdown table. - // llmock mirrors the input, so this table becomes the assistant response. + // AIMock echoes the input, so this table becomes the assistant response. const wideTable = "| Column A | Column B | Column C | Column D | Column E |\n" + "|----------|----------|----------|----------|----------|\n" + @@ -82,21 +84,21 @@ test.describe("Chat", () => { await chatPage.assertLastResponse("first message"); await chatPage.waitForIdle(); - await chatPage.selectModel("gpt-4o-mini"); + await chatPage.selectModel("gpt-4"); await chatPage.sendMessage("second message"); await chatPage.assertLastResponse("second message"); await chatPage.waitForIdle(); await chatPage.assertAssistantMessageModelName(0, "gpt-4o"); - await chatPage.assertAssistantMessageModelName(1, "gpt-4o-mini"); + await chatPage.assertAssistantMessageModelName(1, "gpt-4"); }); test("New Chat sidebar item clears the conversation", async ({ page }) => { const chatPage = new ChatPage(page); await chatPage.navigateTo(); - await chatPage.selectFirstModel(); + await chatPage.selectModel("gpt-4o"); await chatPage.sendMessage("hello"); await chatPage.assertLastResponse("hello"); diff --git a/e2e_tests/tests_omni_light/src/pages.ts b/e2e_tests/tests_omni_light/src/pages.ts index 3f3cd34..d52719f 100644 --- a/e2e_tests/tests_omni_light/src/pages.ts +++ b/e2e_tests/tests_omni_light/src/pages.ts @@ -32,31 +32,15 @@ export class ChatPage { async navigateTo(): Promise { const sidebar = new Sidebar(this.page); - await sidebar.navigateTo("New Chat"); + await sidebar.openChatSection(); + await this.page + .locator('[data-sidebar="menu-sub-button"]', { hasText: "New" }) + .click(); await expect(this.page).toHaveURL(/\/chat\/[\w-]+/); } - async selectFirstModel(): Promise { - // Open the model selector popover and click the first option - const modelButton = this.page - .locator("button") - .filter({ - hasText: /Select model|gpt-/i, - }) - .first(); - await modelButton.waitFor({ state: "visible", timeout: 10000 }); - await modelButton.click(); - // Click the first option in the popover/command list - const firstOption = this.page.locator('[role="option"]').first(); - await firstOption.waitFor({ state: "visible", timeout: 5000 }); - await firstOption.click(); - } - async selectModel(modelName: string): Promise { - const modelButton = this.page - .locator("button") - .filter({ hasText: /Select model|gpt-/i }) - .first(); + const modelButton = this.page.getByTestId("model-selector-button"); await modelButton.waitFor({ state: "visible", timeout: 10000 }); await modelButton.click(); const option = this.page @@ -69,7 +53,10 @@ export class ChatPage { async startNewChat(): Promise { const sidebar = new Sidebar(this.page); - await sidebar.navigateTo("New Chat"); + await sidebar.openChatSection(); + await this.page + .locator('[data-sidebar="menu-sub-button"]', { hasText: "New" }) + .click(); } async assertChatIsEmpty(): Promise { @@ -176,4 +163,39 @@ export class Sidebar { await this.close(); } } + + async openChatSection(): Promise { + await this.open(); + // Expand the Chat collapsible if not already open. + // chatNavigationItem uses a Svelte $state toggle (no data-state attribute), + // so we detect the open state by checking if the "New" sub-button is visible. + const newButton = this.page.locator( + '[data-sidebar="menu-sub-button"]', + { + hasText: "New", + }, + ); + const isAlreadyOpen = await newButton.isVisible(); + if (!isAlreadyOpen) { + await this.page + .getByRole("button", { name: "Chat", exact: true }) + .click(); + } + } + + async openGlobalSettingsSection(): Promise { + await this.open(); + // Global Settings uses shadcn Collapsible.Root (bind:open), so the + // Providers sub-button is only visible when the section is expanded. + const providersButton = this.page.locator( + '[data-sidebar="menu-sub-button"]', + { hasText: "Providers" }, + ); + const isAlreadyOpen = await providersButton.isVisible(); + if (!isAlreadyOpen) { + await this.page + .getByRole("button", { name: "Global Settings", exact: true }) + .click(); + } + } } From 84f160a0c0279821306d00e83c3f93a927583154 Mon Sep 17 00:00:00 2001 From: guenhter Date: Wed, 6 May 2026 06:08:43 +0200 Subject: [PATCH 3/4] refactor: restructure sidebar --- .../tests_omni_light/scripts/run-frontend.sh | 14 ++- e2e_tests/tests_omni_light/src/pages.ts | 49 ++++++++++ .../src/user-settings.spec.ts | 60 ++++++++++++ frontend/omni/CHANGELOG.md | 5 + frontend/omni/package.json | 2 +- frontend/omni/pnpm-lock.yaml | 28 +++++- .../omni/public/modules_browser_only.json | 42 +++++++- .../omni/public/modules_with_backend.json | 42 +++++++- .../src/modules/chat/ChatModelSelector.svelte | 1 + .../modules/chat/chatNavigationItem.svelte | 44 ++++++--- .../omni/src/modules/chat/chatNewItem.svelte | 17 ++++ .../omni/src/modules/chat/locales/de.json | 2 + .../globalSettingsNavigationItem.svelte | 49 ++++++++++ .../modules/global-settings/locales/de.json | 3 + .../providersNavigationItem.svelte | 14 +-- .../providersRouteDefinition.svelte.ts | 6 +- .../SidebarLayout.svelte | 97 ++++++++++++++++--- .../user-settings/LocalizationRoute.svelte | 45 +++++++++ .../src/modules/user-settings/locales/de.json | 7 ++ .../localizationNavigationItem.svelte | 18 ++++ .../localizationRouteDefinition.svelte.ts | 12 +++ .../userSettingsNavigationItem.svelte | 49 ++++++++++ 22 files changed, 559 insertions(+), 47 deletions(-) create mode 100644 e2e_tests/tests_omni_light/src/user-settings.spec.ts create mode 100644 frontend/omni/src/modules/chat/chatNewItem.svelte create mode 100644 frontend/omni/src/modules/global-settings/globalSettingsNavigationItem.svelte create mode 100644 frontend/omni/src/modules/global-settings/locales/de.json create mode 100644 frontend/omni/src/modules/user-settings/LocalizationRoute.svelte create mode 100644 frontend/omni/src/modules/user-settings/locales/de.json create mode 100644 frontend/omni/src/modules/user-settings/localizationNavigationItem.svelte create mode 100644 frontend/omni/src/modules/user-settings/localizationRouteDefinition.svelte.ts create mode 100644 frontend/omni/src/modules/user-settings/userSettingsNavigationItem.svelte diff --git a/e2e_tests/tests_omni_light/scripts/run-frontend.sh b/e2e_tests/tests_omni_light/scripts/run-frontend.sh index 9c98061..b70f266 100755 --- a/e2e_tests/tests_omni_light/scripts/run-frontend.sh +++ b/e2e_tests/tests_omni_light/scripts/run-frontend.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Builds and starts the Vite preview server for e2e light tests. +# Builds the frontend and starts a Caddy Docker container serving the dist for e2e light tests. # # Uses frontend/omni browser-only manifest composed with the test fixtures # modules.json (which includes modules_browser_only.json and adds the external @@ -22,4 +22,14 @@ cp "$LIGHT_TESTS_DIR/fixtures/modules.json" public/modules.json pnpm install pnpm build -exec pnpm preview + +docker container run --rm -i \ + -p 4173:80 \ + -v "$FRONTEND_DIR/dist:/usr/share/caddy:ro" \ + caddy:alpine caddy run --adapter caddyfile --config /dev/stdin <<'CADDYEOF' +:80 { + root * /usr/share/caddy + try_files {path} /index.html + file_server +} +CADDYEOF diff --git a/e2e_tests/tests_omni_light/src/pages.ts b/e2e_tests/tests_omni_light/src/pages.ts index d52719f..6606758 100644 --- a/e2e_tests/tests_omni_light/src/pages.ts +++ b/e2e_tests/tests_omni_light/src/pages.ts @@ -198,4 +198,53 @@ export class Sidebar { .click(); } } + + async openUserSettingsSection(): Promise { + await this.open(); + // User Settings uses shadcn Collapsible.Root (bind:open), so the + // Language & Region sub-button is only visible when the section is expanded. + const languageButton = this.page.locator( + '[data-sidebar="menu-sub-button"]', + { hasText: "Language & Region" }, + ); + const isAlreadyOpen = await languageButton.isVisible(); + if (!isAlreadyOpen) { + await this.page + .getByRole("button", { name: "User Settings", exact: true }) + .click(); + } + } +} + +export class UserSettingsPage { + constructor(private page: Page) {} + + async goto(): Promise { + await this.page.goto("/user-settings/localization"); + } + + async navigateTo(): Promise { + const sidebar = new Sidebar(this.page); + await sidebar.openUserSettingsSection(); + await this.page + .locator('[data-sidebar="menu-sub-button"]', { + hasText: "Language & Region", + }) + .click(); + await expect(this.page).toHaveURL("/user-settings/localization"); + } + + async assertLanguageSelectorVisible(): Promise { + await expect(this.page.locator("#language-select")).toBeVisible(); + } + + async selectLanguage(langCode: string): Promise { + await this.page.locator("#language-select").selectOption(langCode); + } + + async assertSelectedLanguage(langCode: string): Promise { + await expect(this.page.locator("#language-select")).toHaveValue( + langCode, + ); + } } diff --git a/e2e_tests/tests_omni_light/src/user-settings.spec.ts b/e2e_tests/tests_omni_light/src/user-settings.spec.ts new file mode 100644 index 0000000..af1a9d3 --- /dev/null +++ b/e2e_tests/tests_omni_light/src/user-settings.spec.ts @@ -0,0 +1,60 @@ +import { expect, test } from "@playwright/test"; +import { General, UserSettingsPage } from "./pages"; + +test.describe("User Settings - Localization", () => { + test.beforeEach(async ({ page }) => { + await page.goto("/chat"); + await page.evaluate(() => localStorage.clear()); + await page.goto("/chat"); + await page + .getByText("How can I help you today?") + .waitFor({ state: "visible", timeout: 20000 }); + }); + + test("navigates to language settings via sidebar", async ({ page }) => { + const userSettingsPage = new UserSettingsPage(page); + await userSettingsPage.navigateTo(); + await userSettingsPage.assertLanguageSelectorVisible(); + }); + + test("shows English as default language", async ({ page }) => { + const userSettingsPage = new UserSettingsPage(page); + await userSettingsPage.goto(); + await userSettingsPage.assertSelectedLanguage("en"); + }); + + test("changes language to German and persists it", async ({ page }) => { + const userSettingsPage = new UserSettingsPage(page); + await userSettingsPage.goto(); + await userSettingsPage.selectLanguage("de"); + + // Navigate away and back to verify persistence via localStorage + await page.goto("/chat"); + await page + .getByRole("heading", { + name: "Wie kann ich Ihnen heute helfen?", + exact: true, + }) + .waitFor({ state: "visible", timeout: 10000 }); + + await userSettingsPage.goto(); + await userSettingsPage.assertSelectedLanguage("de"); + }); + + test("language change reflects in the UI immediately", async ({ page }) => { + const userSettingsPage = new UserSettingsPage(page); + const general = new General(page); + await general.setLanguage("en"); + await userSettingsPage.goto(); + + await userSettingsPage.selectLanguage("de"); + + // The page title should switch to German immediately without reloading + await expect( + page.getByRole("heading", { + name: "Sprache & Lokalisierung", + exact: true, + }), + ).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/frontend/omni/CHANGELOG.md b/frontend/omni/CHANGELOG.md index 1552bbd..5cfcf10 100644 --- a/frontend/omni/CHANGELOG.md +++ b/frontend/omni/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- The sidebar is now resizable. +- Chat sidebar entry is now a collapsible menu. + ### Fixed - Added missing favicon `` tags to `index.html`; browsers now display the modAI icon. Added `favicon.png` (32×32) as a PNG fallback alongside the existing `modai.svg`. diff --git a/frontend/omni/package.json b/frontend/omni/package.json index cc23da8..fca71ef 100644 --- a/frontend/omni/package.json +++ b/frontend/omni/package.json @@ -31,7 +31,7 @@ "devDependencies": { "@biomejs/biome": "^2.4.13", "@internationalized/date": "^3.12.1", - "@lucide/svelte": "^1.11.0", + "@lucide/svelte": "^1.14.0", "@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "^4.2.4", "bits-ui": "^2.18.0", diff --git a/frontend/omni/pnpm-lock.yaml b/frontend/omni/pnpm-lock.yaml index 44b9866..731d453 100644 --- a/frontend/omni/pnpm-lock.yaml +++ b/frontend/omni/pnpm-lock.yaml @@ -61,8 +61,8 @@ importers: specifier: ^3.12.1 version: 3.12.1 '@lucide/svelte': - specifier: ^1.11.0 - version: 1.11.0(svelte@5.55.5(@typescript-eslint/types@8.57.2)) + specifier: ^1.14.0 + version: 1.14.0(svelte@5.55.5(@typescript-eslint/types@8.57.2)) '@sveltejs/vite-plugin-svelte': specifier: ^7.0.0 version: 7.0.0(svelte@5.55.5(@typescript-eslint/types@8.57.2))(vite@8.0.10(@types/node@25.6.0)(jiti@2.6.1)) @@ -143,24 +143,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.13': resolution: {integrity: sha512-NzkUDSqfvMBrPplKgVr3aXLHZ2NEELvvF4vZxXulEylKWIGqlvNEcwUcj9OLrn75TD3lJ/GIqCVlBwd1MZCuYQ==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.13': resolution: {integrity: sha512-Z601MienRgTBDza/+u2CH3RSrWoXo9rtr8NK6A4KJzqGgfxx+H3VlyLgTJ4sRo40T3pIsqpTmiOQEvYzQvBRvQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.13': resolution: {integrity: sha512-Az3ZZedYRBo9EQzNnD9SxFcR1G5QsGo6VEc2hIyVPZ1rdKwee/7E9oeBBZFpE8Z44ekxsDQBqbiWGW5ShOhUSQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.13': resolution: {integrity: sha512-Px9PS2B5/Q183bUwy/5VHqp3J2lzdOCeVGzMpphYfl8oSa7VDCqenBdqWpy6DCy/en4Rbf/Y1RieZF6dJPcc9A==} @@ -211,8 +215,8 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@lucide/svelte@1.11.0': - resolution: {integrity: sha512-4I9b+RuxIf8OkGG883bIYDIXjqVOH0ClgrB0j+jI6CrzEziroEwxiubQwW2xKT3esdJcRVTtW9RtCQM9D+r+ew==} + '@lucide/svelte@1.14.0': + resolution: {integrity: sha512-MVuP5VRCBQa2OaIpaRbuEV4k5OV2dy9MyxA6Tf4Sz/IN0v3zzUU72ObHc9r2Zn/wSMXDg6lLrHrczqI7w7gCzQ==} peerDependencies: svelte: ^5 @@ -264,36 +268,42 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] + libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] + libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} @@ -377,24 +387,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.4': resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.4': resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.4': resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.4': resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} @@ -680,24 +694,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -1099,7 +1117,7 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@lucide/svelte@1.11.0(svelte@5.55.5(@typescript-eslint/types@8.57.2))': + '@lucide/svelte@1.14.0(svelte@5.55.5(@typescript-eslint/types@8.57.2))': dependencies: svelte: 5.55.5(@typescript-eslint/types@8.57.2) diff --git a/frontend/omni/public/modules_browser_only.json b/frontend/omni/public/modules_browser_only.json index 22080d2..4642b28 100644 --- a/frontend/omni/public/modules_browser_only.json +++ b/frontend/omni/public/modules_browser_only.json @@ -17,6 +17,7 @@ "module:routes": [ "chat-route", "providers-route", + "localization-route", "chat-fallback-route", "sidebar-layout-route" ] @@ -29,7 +30,8 @@ "dependencies": { "module:sidebarTopItems": [ "chat-navigation-item", - "providers-navigation-item" + "user-settings-navigation-item", + "global-settings-navigation-item" ], "module:sidebarBottomItems": [] } @@ -85,6 +87,14 @@ "id": "chat-navigation-item", "type": "SidebarTopItem", "path": "@/modules/chat/chatNavigationItem", + "dependencies": { + "module:chatSidebarItems": ["chat-new-item"] + } + }, + { + "id": "chat-new-item", + "type": "ChatSidebarItem", + "path": "@/modules/chat/chatNewItem", "dependencies": {} }, { @@ -118,8 +128,16 @@ "dependencies": {} }, { - "id": "providers-navigation-item", + "id": "global-settings-navigation-item", "type": "SidebarTopItem", + "path": "@/modules/global-settings/globalSettingsNavigationItem", + "dependencies": { + "module:globalSettingsSidebarItems": ["providers-navigation-item"] + } + }, + { + "id": "providers-navigation-item", + "type": "GlobalSettingsSidebarItem", "path": "@/modules/llm-provider-management/providersNavigationItem", "dependencies": {} }, @@ -130,6 +148,26 @@ "dependencies": { "module:providerManagement": ["llm-provider-management"] } + }, + { + "id": "user-settings-navigation-item", + "type": "SidebarTopItem", + "path": "@/modules/user-settings/userSettingsNavigationItem", + "dependencies": { + "module:userSettingsSidebarItems": ["localization-navigation-item"] + } + }, + { + "id": "localization-navigation-item", + "type": "UserSettingsSidebarItem", + "path": "@/modules/user-settings/localizationNavigationItem", + "dependencies": {} + }, + { + "id": "localization-route", + "type": "Route", + "path": "@/modules/user-settings/localizationRouteDefinition/create", + "dependencies": {} } ] } diff --git a/frontend/omni/public/modules_with_backend.json b/frontend/omni/public/modules_with_backend.json index 5a3e7f6..99e8c38 100644 --- a/frontend/omni/public/modules_with_backend.json +++ b/frontend/omni/public/modules_with_backend.json @@ -27,6 +27,7 @@ "module:routes": [ "chat-route", "providers-route", + "localization-route", "chat-fallback-route", "sidebar-layout-route" ] @@ -39,7 +40,8 @@ "dependencies": { "module:sidebarTopItems": [ "chat-navigation-item", - "providers-navigation-item" + "user-settings-navigation-item", + "global-settings-navigation-item" ], "module:sidebarBottomItems": ["logout-item", "user-avatar-item"] } @@ -121,6 +123,14 @@ "id": "chat-navigation-item", "type": "SidebarTopItem", "path": "@/modules/chat/chatNavigationItem", + "dependencies": { + "module:chatSidebarItems": ["chat-new-item"] + } + }, + { + "id": "chat-new-item", + "type": "ChatSidebarItem", + "path": "@/modules/chat/chatNewItem", "dependencies": {} }, { @@ -156,8 +166,16 @@ } }, { - "id": "providers-navigation-item", + "id": "global-settings-navigation-item", "type": "SidebarTopItem", + "path": "@/modules/global-settings/globalSettingsNavigationItem", + "dependencies": { + "module:globalSettingsSidebarItems": ["providers-navigation-item"] + } + }, + { + "id": "providers-navigation-item", + "type": "GlobalSettingsSidebarItem", "path": "@/modules/llm-provider-management/providersNavigationItem", "dependencies": {} }, @@ -169,6 +187,26 @@ "module:providerManagement": ["llm-provider-management"] } }, + { + "id": "user-settings-navigation-item", + "type": "SidebarTopItem", + "path": "@/modules/user-settings/userSettingsNavigationItem", + "dependencies": { + "module:userSettingsSidebarItems": ["localization-navigation-item"] + } + }, + { + "id": "localization-navigation-item", + "type": "UserSettingsSidebarItem", + "path": "@/modules/user-settings/localizationNavigationItem", + "dependencies": {} + }, + { + "id": "localization-route", + "type": "Route", + "path": "@/modules/user-settings/localizationRouteDefinition/create", + "dependencies": {} + }, { "id": "logout-item", "path": "@/modules/authentication/LogoutItem", diff --git a/frontend/omni/src/modules/chat/ChatModelSelector.svelte b/frontend/omni/src/modules/chat/ChatModelSelector.svelte index c36bbe4..07c53db 100644 --- a/frontend/omni/src/modules/chat/ChatModelSelector.svelte +++ b/frontend/omni/src/modules/chat/ChatModelSelector.svelte @@ -34,6 +34,7 @@ function handleSelect(selectId: string) { variant="ghost" size="sm" class="text-muted-foreground h-auto gap-1.5 px-2 py-1 text-xs" + data-testid="model-selector-button" {...props} > {selectedModelData?.modelName ?? t("selectModel", { defaultValue: "Select model" })} diff --git a/frontend/omni/src/modules/chat/chatNavigationItem.svelte b/frontend/omni/src/modules/chat/chatNavigationItem.svelte index 8a7286d..0f8d5a8 100644 --- a/frontend/omni/src/modules/chat/chatNavigationItem.svelte +++ b/frontend/omni/src/modules/chat/chatNavigationItem.svelte @@ -1,22 +1,44 @@ - - router.navigate(CHAT_PATH)} - > - - {t("navLabel", { defaultValue: "New Chat" })} - - + + + { + isOpen = !isOpen; + }} + > + + {t("chatLabel", { defaultValue: "Chat" })} + + + + + {#each chatSidebarItems as ChatSidebarItem, index (index)} + + {/each} + + + + diff --git a/frontend/omni/src/modules/chat/chatNewItem.svelte b/frontend/omni/src/modules/chat/chatNewItem.svelte new file mode 100644 index 0000000..7427ac5 --- /dev/null +++ b/frontend/omni/src/modules/chat/chatNewItem.svelte @@ -0,0 +1,17 @@ + + + + router.navigate(CHAT_PATH)}> + + {t("newLabel", { defaultValue: "New" })} + + diff --git a/frontend/omni/src/modules/chat/locales/de.json b/frontend/omni/src/modules/chat/locales/de.json index c2ed4c5..34c20a3 100644 --- a/frontend/omni/src/modules/chat/locales/de.json +++ b/frontend/omni/src/modules/chat/locales/de.json @@ -14,6 +14,8 @@ "thinking": "Denke nach...", "generating": "Generiere...", "navLabel": "Neuer Chat", + "chatLabel": "Chat", + "newLabel": "Neu", "suggestions": [ "Was sind die neuesten KI-Trends?", "Wie funktioniert maschinelles Lernen?", diff --git a/frontend/omni/src/modules/global-settings/globalSettingsNavigationItem.svelte b/frontend/omni/src/modules/global-settings/globalSettingsNavigationItem.svelte new file mode 100644 index 0000000..296ca30 --- /dev/null +++ b/frontend/omni/src/modules/global-settings/globalSettingsNavigationItem.svelte @@ -0,0 +1,49 @@ + + + + + { + isOpen = !isOpen; + }} + > + + {t("navLabel", { defaultValue: "Global Settings" })} + + + + + {#each sidebarItems as SidebarItem, index (index)} + + {/each} + + + + diff --git a/frontend/omni/src/modules/global-settings/locales/de.json b/frontend/omni/src/modules/global-settings/locales/de.json new file mode 100644 index 0000000..deabed4 --- /dev/null +++ b/frontend/omni/src/modules/global-settings/locales/de.json @@ -0,0 +1,3 @@ +{ + "navLabel": "Globale Einstellungen" +} diff --git a/frontend/omni/src/modules/llm-provider-management/providersNavigationItem.svelte b/frontend/omni/src/modules/llm-provider-management/providersNavigationItem.svelte index 3f4ef8e..3aa9013 100644 --- a/frontend/omni/src/modules/llm-provider-management/providersNavigationItem.svelte +++ b/frontend/omni/src/modules/llm-provider-management/providersNavigationItem.svelte @@ -1,22 +1,18 @@ - - router.navigate(PROVIDERS_PATH)} - > + + router.navigate(PROVIDERS_PATH)}> {t("navLabel", { defaultValue: "Providers" })} - - + + diff --git a/frontend/omni/src/modules/llm-provider-management/providersRouteDefinition.svelte.ts b/frontend/omni/src/modules/llm-provider-management/providersRouteDefinition.svelte.ts index 7915d07..c571557 100644 --- a/frontend/omni/src/modules/llm-provider-management/providersRouteDefinition.svelte.ts +++ b/frontend/omni/src/modules/llm-provider-management/providersRouteDefinition.svelte.ts @@ -1,10 +1,12 @@ import type { Routes } from "../router/index.svelte"; import ProvidersRoute from "./ProvidersRoute.svelte"; -export const PROVIDERS_PATH = "/providers"; +export const PROVIDERS_PATH = "/settings/providers"; export function create(): Routes { return { - [PROVIDERS_PATH]: ProvidersRoute, + "/settings": { + "/providers": ProvidersRoute, + }, }; } diff --git a/frontend/omni/src/modules/main-app-sidebar-based/SidebarLayout.svelte b/frontend/omni/src/modules/main-app-sidebar-based/SidebarLayout.svelte index 3040d40..c93f4ed 100644 --- a/frontend/omni/src/modules/main-app-sidebar-based/SidebarLayout.svelte +++ b/frontend/omni/src/modules/main-app-sidebar-based/SidebarLayout.svelte @@ -1,5 +1,5 @@ - - -
- -
-
- {@render children?.()} -
-
+// Drag-to-resize +const MIN_WIDTH_PX = 160; +const COLLAPSE_ZONE_PX = 120; +const MAX_WIDTH_PX = 520; +const COLLAPSE_THRESHOLD_X = MIN_WIDTH_PX - COLLAPSE_ZONE_PX; + +let sidebarOpen = $state(true); +let wrapperRef = $state(null); + +function handleDrag(e: PointerEvent) { + const handle = e.currentTarget as HTMLElement; + handle.setPointerCapture(e.pointerId); + const wrapper = wrapperRef; + if (!wrapper) return; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + wrapper.dataset.dragging = "true"; + let pending = false; + let latestX = e.clientX; + function cleanup() { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + delete wrapper?.dataset.dragging; + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + function onMove(ev: PointerEvent) { + latestX = ev.clientX; + if (pending) return; + pending = true; + requestAnimationFrame(() => { + pending = false; + if (latestX >= MIN_WIDTH_PX) { + wrapper?.style.setProperty( + "--sidebar-width", + `${Math.min(MAX_WIDTH_PX, latestX)}px`, + ); + if (!sidebarOpen) sidebarOpen = true; + } else if (latestX > COLLAPSE_THRESHOLD_X) { + if (sidebarOpen) + wrapper?.style.setProperty( + "--sidebar-width", + `${MIN_WIDTH_PX}px`, + ); + } else { + if (sidebarOpen) sidebarOpen = false; + } + }); + } + function onUp() { + cleanup(); + } + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); +} + - + +
@@ -35,7 +82,7 @@ let {

{t("workspaceSubtitle", { defaultValue: "Modular AI Workspace" })}

- +
@@ -47,5 +94,29 @@ let { {@render sidebarFooter()} {/if} + + +
+ + +
+ +
+
+ {@render children?.()} +
+
+ + diff --git a/frontend/omni/src/modules/user-settings/LocalizationRoute.svelte b/frontend/omni/src/modules/user-settings/LocalizationRoute.svelte new file mode 100644 index 0000000..274f751 --- /dev/null +++ b/frontend/omni/src/modules/user-settings/LocalizationRoute.svelte @@ -0,0 +1,45 @@ + + +
+
+

+ {t("localizationTitle", { defaultValue: "Language & Localization" })} +

+

+ {t("localizationSubtitle", { defaultValue: "Configure your preferred language." })} +

+
+ +
+ + +
+
diff --git a/frontend/omni/src/modules/user-settings/locales/de.json b/frontend/omni/src/modules/user-settings/locales/de.json new file mode 100644 index 0000000..e26ceaa --- /dev/null +++ b/frontend/omni/src/modules/user-settings/locales/de.json @@ -0,0 +1,7 @@ +{ + "navLabel": "Benutzereinstellungen", + "localizationNavLabel": "Sprache & Region", + "localizationTitle": "Sprache & Lokalisierung", + "localizationSubtitle": "Konfigurieren Sie Ihre bevorzugte Sprache.", + "languageLabel": "Sprache" +} diff --git a/frontend/omni/src/modules/user-settings/localizationNavigationItem.svelte b/frontend/omni/src/modules/user-settings/localizationNavigationItem.svelte new file mode 100644 index 0000000..86c2537 --- /dev/null +++ b/frontend/omni/src/modules/user-settings/localizationNavigationItem.svelte @@ -0,0 +1,18 @@ + + + + router.navigate(LOCALIZATION_PATH)}> + + {t("localizationNavLabel", { defaultValue: "Language & Region" })} + + diff --git a/frontend/omni/src/modules/user-settings/localizationRouteDefinition.svelte.ts b/frontend/omni/src/modules/user-settings/localizationRouteDefinition.svelte.ts new file mode 100644 index 0000000..cd3ee1b --- /dev/null +++ b/frontend/omni/src/modules/user-settings/localizationRouteDefinition.svelte.ts @@ -0,0 +1,12 @@ +import type { Routes } from "../router/index.svelte"; +import LocalizationRoute from "./LocalizationRoute.svelte"; + +export const LOCALIZATION_PATH = "/user-settings/localization"; + +export function create(): Routes { + return { + "/user-settings": { + "/localization": LocalizationRoute, + }, + }; +} diff --git a/frontend/omni/src/modules/user-settings/userSettingsNavigationItem.svelte b/frontend/omni/src/modules/user-settings/userSettingsNavigationItem.svelte new file mode 100644 index 0000000..f759a87 --- /dev/null +++ b/frontend/omni/src/modules/user-settings/userSettingsNavigationItem.svelte @@ -0,0 +1,49 @@ + + + + + { + isOpen = !isOpen; + }} + > + + {t("navLabel", { defaultValue: "User Settings" })} + + + + + {#each sidebarItems as SidebarItem, index (index)} + + {/each} + + + + From 7b687df82d08c8b26ebc02cc5578955d6fb38345 Mon Sep 17 00:00:00 2001 From: guenhter Date: Wed, 6 May 2026 14:58:12 +0200 Subject: [PATCH 4/4] fix: fix tests --- .github/workflows/ci.yml | 4 +++ .../tests_omni_full/playwright.config.ts | 4 +-- .../{start-aimock.sh => run-aimock.sh} | 0 .../{start-nanoidp.sh => run-nanoidp.sh} | 0 e2e_tests/tests_omni_full/src/pages.ts | 8 ++++++ .../tests_omni_light/playwright.config.ts | 2 +- .../tests_omni_light/scripts/run-aimock.sh | 26 +++++++++++++++++++ .../tests_omni_light/scripts/run-frontend.sh | 14 ++-------- .../tests_omni_light/scripts/start-aimock.sh | 16 ------------ e2e_tests/tests_omni_light/src/pages.ts | 12 +++++++++ 10 files changed, 55 insertions(+), 31 deletions(-) rename e2e_tests/tests_omni_full/scripts/{start-aimock.sh => run-aimock.sh} (100%) rename e2e_tests/tests_omni_full/scripts/{start-nanoidp.sh => run-nanoidp.sh} (100%) create mode 100755 e2e_tests/tests_omni_light/scripts/run-aimock.sh delete mode 100755 e2e_tests/tests_omni_light/scripts/start-aimock.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcac04d..c659ab0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -147,6 +147,10 @@ jobs: working-directory: e2e_tests/${{ matrix.name }} run: just install + - name: Install Playwright browsers + working-directory: e2e_tests/${{ matrix.name }} + run: just install-playwright + - name: Run Playwright tests working-directory: e2e_tests/${{ matrix.name }} run: just test diff --git a/e2e_tests/tests_omni_full/playwright.config.ts b/e2e_tests/tests_omni_full/playwright.config.ts index 2d39c2f..c6e3e19 100644 --- a/e2e_tests/tests_omni_full/playwright.config.ts +++ b/e2e_tests/tests_omni_full/playwright.config.ts @@ -42,7 +42,7 @@ export default defineConfig({ webServer: [ { name: "NanoIDP", - command: "bash scripts/start-nanoidp.sh", + command: "bash scripts/run-nanoidp.sh", url: "http://localhost:9000/api/health", reuseExistingServer: !process.env.CI, gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }, @@ -64,7 +64,7 @@ export default defineConfig({ }, { name: "AIMock", - command: "bash scripts/start-aimock.sh", + command: "bash scripts/run-aimock.sh", url: "http://localhost:4010/health", reuseExistingServer: !process.env.CI, gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }, diff --git a/e2e_tests/tests_omni_full/scripts/start-aimock.sh b/e2e_tests/tests_omni_full/scripts/run-aimock.sh similarity index 100% rename from e2e_tests/tests_omni_full/scripts/start-aimock.sh rename to e2e_tests/tests_omni_full/scripts/run-aimock.sh diff --git a/e2e_tests/tests_omni_full/scripts/start-nanoidp.sh b/e2e_tests/tests_omni_full/scripts/run-nanoidp.sh similarity index 100% rename from e2e_tests/tests_omni_full/scripts/start-nanoidp.sh rename to e2e_tests/tests_omni_full/scripts/run-nanoidp.sh diff --git a/e2e_tests/tests_omni_full/src/pages.ts b/e2e_tests/tests_omni_full/src/pages.ts index f5cfd3c..03fab14 100644 --- a/e2e_tests/tests_omni_full/src/pages.ts +++ b/e2e_tests/tests_omni_full/src/pages.ts @@ -239,10 +239,14 @@ export class Sidebar { } async openChatSubMenu(subItem: string): Promise { + const wasOpen = await this.isOpen(); await this.openChatSection(); await this.page .locator('[data-sidebar="menu-sub-button"]', { hasText: subItem }) .click(); + if (!wasOpen) { + await this.close(); + } } async openGlobalSettingsSection(): Promise { @@ -262,10 +266,14 @@ export class Sidebar { } async openGlobalSettingsSubMenu(subItem: string): Promise { + const wasOpen = await this.isOpen(); await this.openGlobalSettingsSection(); await this.page .locator('[data-sidebar="menu-sub-button"]', { hasText: subItem }) .click(); + if (!wasOpen) { + await this.close(); + } } async logout(): Promise { diff --git a/e2e_tests/tests_omni_light/playwright.config.ts b/e2e_tests/tests_omni_light/playwright.config.ts index 93037a9..39d8dbb 100644 --- a/e2e_tests/tests_omni_light/playwright.config.ts +++ b/e2e_tests/tests_omni_light/playwright.config.ts @@ -49,7 +49,7 @@ export default defineConfig({ }, { name: "AIMock", - command: "bash scripts/start-aimock.sh", + command: "bash scripts/run-aimock.sh", url: "http://localhost:4010/health", reuseExistingServer: !process.env.CI, gracefulShutdown: { signal: "SIGTERM", timeout: 10_000 }, diff --git a/e2e_tests/tests_omni_light/scripts/run-aimock.sh b/e2e_tests/tests_omni_light/scripts/run-aimock.sh new file mode 100755 index 0000000..1c89b9f --- /dev/null +++ b/e2e_tests/tests_omni_light/scripts/run-aimock.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Starts AIMock for e2e tests using the fixture file from tests_omni_light/src/. +# +# Lifecycle managed by Playwright's webServer config: +# - url: http://localhost:4010/health +# +# WORKAROUND: AIMock's CORS preflight response only allows "Content-Type, Authorization" +# in Access-Control-Allow-Headers. Firefox (unlike Chrome) includes the "user-agent" header +# in preflight requests when the OpenAI SDK sets it, which causes CORS blocks. +# We patch server.js inside the container to use "*" instead. +# Remove this workaround once https://github.com/CopilotKit/aimock/issues/158 is resolved. + +set -euo pipefail + +SCRIPT_DIR=$(dirname "$(realpath "$0")") +FIXTURES_FILE="$SCRIPT_DIR/../src/aimock-fixtures.json" + +docker container rm -f e2e-light-aimock 2>/dev/null || true +docker container run --rm --pull always \ + --name e2e-light-aimock \ + -p 4010:4010 \ + -v "$FIXTURES_FILE:/fixtures/aimock-fixtures.json:ro" \ + --entrypoint /bin/sh \ + ghcr.io/copilotkit/aimock:latest \ + -c 'sed -i "s/\"Access-Control-Allow-Headers\": \"Content-Type, Authorization\"/\"Access-Control-Allow-Headers\": \"*\"/" dist/server.js && \ + node dist/cli.js --fixtures /fixtures/aimock-fixtures.json --host 0.0.0.0' diff --git a/e2e_tests/tests_omni_light/scripts/run-frontend.sh b/e2e_tests/tests_omni_light/scripts/run-frontend.sh index b70f266..9c98061 100755 --- a/e2e_tests/tests_omni_light/scripts/run-frontend.sh +++ b/e2e_tests/tests_omni_light/scripts/run-frontend.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Builds the frontend and starts a Caddy Docker container serving the dist for e2e light tests. +# Builds and starts the Vite preview server for e2e light tests. # # Uses frontend/omni browser-only manifest composed with the test fixtures # modules.json (which includes modules_browser_only.json and adds the external @@ -22,14 +22,4 @@ cp "$LIGHT_TESTS_DIR/fixtures/modules.json" public/modules.json pnpm install pnpm build - -docker container run --rm -i \ - -p 4173:80 \ - -v "$FRONTEND_DIR/dist:/usr/share/caddy:ro" \ - caddy:alpine caddy run --adapter caddyfile --config /dev/stdin <<'CADDYEOF' -:80 { - root * /usr/share/caddy - try_files {path} /index.html - file_server -} -CADDYEOF +exec pnpm preview diff --git a/e2e_tests/tests_omni_light/scripts/start-aimock.sh b/e2e_tests/tests_omni_light/scripts/start-aimock.sh deleted file mode 100755 index 89fc156..0000000 --- a/e2e_tests/tests_omni_light/scripts/start-aimock.sh +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -# Starts AIMock for e2e tests using the fixture file from tests_omni_light/src/. -# -# Lifecycle managed by Playwright's webServer config: -# - url: http://localhost:4010/health - -set -euo pipefail - -SCRIPT_DIR=$(dirname "$(realpath "$0")") -FIXTURES_FILE="$SCRIPT_DIR/../src/aimock-fixtures.json" - -docker container run --rm --pull always \ - -p 4010:4010 \ - -v "$FIXTURES_FILE:/fixtures/aimock-fixtures.json:ro" \ - ghcr.io/copilotkit/aimock:latest \ - --fixtures /fixtures/aimock-fixtures.json --host 0.0.0.0 diff --git a/e2e_tests/tests_omni_light/src/pages.ts b/e2e_tests/tests_omni_light/src/pages.ts index 6606758..e0ba2b0 100644 --- a/e2e_tests/tests_omni_light/src/pages.ts +++ b/e2e_tests/tests_omni_light/src/pages.ts @@ -32,11 +32,15 @@ export class ChatPage { async navigateTo(): Promise { const sidebar = new Sidebar(this.page); + const wasOpen = await sidebar.isOpen(); await sidebar.openChatSection(); await this.page .locator('[data-sidebar="menu-sub-button"]', { hasText: "New" }) .click(); await expect(this.page).toHaveURL(/\/chat\/[\w-]+/); + if (!wasOpen) { + await sidebar.close(); + } } async selectModel(modelName: string): Promise { @@ -53,10 +57,14 @@ export class ChatPage { async startNewChat(): Promise { const sidebar = new Sidebar(this.page); + const wasOpen = await sidebar.isOpen(); await sidebar.openChatSection(); await this.page .locator('[data-sidebar="menu-sub-button"]', { hasText: "New" }) .click(); + if (!wasOpen) { + await sidebar.close(); + } } async assertChatIsEmpty(): Promise { @@ -225,6 +233,7 @@ export class UserSettingsPage { async navigateTo(): Promise { const sidebar = new Sidebar(this.page); + const wasOpen = await sidebar.isOpen(); await sidebar.openUserSettingsSection(); await this.page .locator('[data-sidebar="menu-sub-button"]', { @@ -232,6 +241,9 @@ export class UserSettingsPage { }) .click(); await expect(this.page).toHaveURL("/user-settings/localization"); + if (!wasOpen) { + await sidebar.close(); + } } async assertLanguageSelectorVisible(): Promise {