Преглед на файлове

feat: add session runtime targets and SSE streaming execution

Add runtime target fields (agent/team) to sessions with migration,
implement SSE streaming for session execute via gateway orchestration,
update frontend to stream assistant responses in real-time with typing
indicator, rename platform to AgentDock, remove unused core-dsl dep.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Jax Docker преди 1 месец
родител
ревизия
2f826a8c6b

+ 0 - 1
deployments/docker/python-service.Dockerfile

@@ -15,7 +15,6 @@ COPY services ./services
 RUN pip install --no-cache-dir \
 RUN pip install --no-cache-dir \
     -e ./libs/core-shared \
     -e ./libs/core-shared \
     -e ./libs/core-domain \
     -e ./libs/core-domain \
-    -e ./libs/core-dsl \
     -e ./libs/core-events \
     -e ./libs/core-events \
     -e ./libs/core-db \
     -e ./libs/core-db \
     -e ./${SERVICE_PATH}
     -e ./${SERVICE_PATH}

+ 515 - 1
services/api-gateway/app/api/routes.py

@@ -1,16 +1,20 @@
 import asyncio
 import asyncio
+import json
 from typing import Annotated
 from typing import Annotated
 
 
 from core_domain import ServiceDescriptor, ServiceHealth
 from core_domain import ServiceDescriptor, ServiceHealth
-from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response
+import httpx
+from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, StreamingResponse
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
+from pydantic import BaseModel
 
 
 from app.bootstrap.settings import ApiGatewaySettings
 from app.bootstrap.settings import ApiGatewaySettings
 from app.db.session import get_db
 from app.db.session import get_db
 from app.domain.repositories import ApiKeyRepository, GatewayRequestAuditRepository
 from app.domain.repositories import ApiKeyRepository, GatewayRequestAuditRepository
 from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
 from app.infrastructure.api_keys import generate_api_key, get_api_key_prefix, hash_api_key
 from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
 from app.infrastructure.proxy import ProxyServiceName, ProxyTarget, ServiceProxy
+from core_shared.security import build_internal_service_headers
 from app.schemas.gateway import (
 from app.schemas.gateway import (
     ApiKeyCreateRequest,
     ApiKeyCreateRequest,
     ApiKeyCreateResponse,
     ApiKeyCreateResponse,
@@ -28,6 +32,27 @@ router = APIRouter()
 DbSession = Annotated[Session, Depends(get_db)]
 DbSession = Annotated[Session, Depends(get_db)]
 
 
 
 
+class SessionExecuteRequest(BaseModel):
+    session_id: str
+    message_text: str
+    stream: bool = False
+
+
+class SessionExecuteResponse(BaseModel):
+    session_id: str
+    run_request_id: str
+    target_type: str
+    target_id: str
+    target_config_id: str | None = None
+    request_status: str
+    user_message_id: str
+    assistant_message_id: str | None = None
+    agent_run_id: str | None = None
+    team_run_id: str | None = None
+    output_text: str | None = None
+    error_message: str | None = None
+
+
 @router.get("/health", response_model=ServiceDescriptor)
 @router.get("/health", response_model=ServiceDescriptor)
 def health_check(db: DbSession) -> ServiceDescriptor:
 def health_check(db: DbSession) -> ServiceDescriptor:
     db.execute(text("SELECT 1"))
     db.execute(text("SELECT 1"))
@@ -241,6 +266,372 @@ async def downstream_health_check(
         downstream_services=downstream_services)
         downstream_services=downstream_services)
 
 
 
 
+@router.post("/gateway/sessions/execute")
+async def execute_session(
+    payload: SessionExecuteRequest,
+    request: Request,
+    settings: GatewaySettingsDep):
+    if payload.stream:
+        return StreamingResponse(
+            _stream_session_execute(payload, request, settings),
+            media_type="text/event-stream",
+            headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"})
+
+    targets = build_proxy_targets(settings)
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    team_target = targets["team-service"]
+    headers = _build_internal_headers(request, settings)
+
+    async with httpx_client(settings.proxy_timeout_seconds) as client:
+        session = await _post_json(
+            client=client,
+            target=session_target,
+            path="detail",
+            payload={"session_id": payload.session_id},
+            headers=headers)
+
+        target_type = _get_string(session, "runtime_target_type")
+        target_id = _get_string(session, "runtime_target_id")
+        target_config_id = _get_optional_string(session, "runtime_target_config_id")
+        if target_type not in {"agent", "team"} or not target_id:
+            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+
+        run_request_payload = {
+            "target_type": target_type,
+            "target_id": target_id,
+            "target_config_id": target_config_id,
+            "mode": "production",
+            "input_text": payload.message_text,
+        }
+        run_request = await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests",
+            payload={
+                "session_id": payload.session_id,
+                "app_config_id": target_config_id or target_id,
+                "workflow_config_id": target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            },
+            headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client,
+            target=session_target,
+            path="messages",
+            payload={
+                "session_id": payload.session_id,
+                "turn_id": run_request_id,
+                "role": "user",
+                "content_type": "text",
+                "content_text": payload.message_text,
+                "content_json": {},
+            },
+            headers=headers)
+        user_message_id = _get_string(user_message, "id")
+
+        await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id,
+                "request_status": "running",
+                "request_payload_json": {
+                    **run_request_payload,
+                    "user_message_id": user_message_id,
+                },
+            },
+            headers=headers)
+
+        assistant_message_id: str | None = None
+        agent_run_id: str | None = None
+        team_run_id: str | None = None
+        output_text: str | None = None
+        error_message: str | None = None
+        request_status = "completed"
+
+        try:
+            if target_type == "agent":
+                agent_run = await _post_json(
+                    client=client,
+                    target=agent_target,
+                    path="runs",
+                    payload={
+                        "agent_id": target_id,
+                        "agent_config_id": target_config_id,
+                        "session_id": payload.session_id,
+                        "input_text": payload.message_text,
+                        "input_json": {
+                            "source": "session",
+                            "run_request_id": run_request_id,
+                        },
+                    },
+                    headers=headers)
+                agent_run_id = _get_string(agent_run, "id")
+                execute_result = await _post_json(
+                    client=client,
+                    target=agent_target,
+                    path="runs/execute",
+                    payload={
+                        "agent_run_id": agent_run_id,
+                        "dry_run": False,
+                    },
+                    headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+            else:
+                team_run = await _post_json(
+                    client=client,
+                    target=team_target,
+                    path="runs",
+                    payload={
+                        "team_id": target_id,
+                        "team_config_id": target_config_id,
+                        "session_id": payload.session_id,
+                        "input_text": payload.message_text,
+                        "input_json": {
+                            "source": "session",
+                            "run_request_id": run_request_id,
+                        },
+                        "enqueue": True,
+                    },
+                    headers=headers)
+                team_run_id = _get_string(team_run, "id")
+                execute_result = await _post_json(
+                    client=client,
+                    target=team_target,
+                    path=f"runs/{team_run_id}/execute",
+                    payload={
+                        "dry_run": False,
+                    },
+                    headers=headers)
+                run_data = _get_dict(execute_result, "run")
+                output_text = _resolve_output_text(run_data)
+                error_message = _get_optional_string(run_data, "error_message")
+
+            if error_message:
+                request_status = "failed"
+
+            if output_text:
+                assistant_message = await _post_json(
+                    client=client,
+                    target=session_target,
+                    path="messages",
+                    payload={
+                        "session_id": payload.session_id,
+                        "turn_id": run_request_id,
+                        "role": "assistant",
+                        "content_type": "text",
+                        "content_text": output_text,
+                        "content_json": {},
+                    },
+                    headers=headers)
+                assistant_message_id = _get_string(assistant_message, "id")
+        except HTTPException as exc:
+            request_status = "failed"
+            error_message = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+
+        await _post_json(
+            client=client,
+            target=session_target,
+            path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id,
+                "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload,
+                    "user_message_id": user_message_id,
+                    "assistant_message_id": assistant_message_id,
+                    "agent_run_id": agent_run_id,
+                    "team_run_id": team_run_id,
+                    "output_text": output_text,
+                    "error_message": error_message,
+                },
+            },
+            headers=headers)
+
+    return SessionExecuteResponse(
+        session_id=payload.session_id,
+        run_request_id=run_request_id,
+        target_type=target_type,
+        target_id=target_id,
+        target_config_id=target_config_id,
+        request_status=request_status,
+        user_message_id=user_message_id,
+        assistant_message_id=assistant_message_id,
+        agent_run_id=agent_run_id,
+        team_run_id=team_run_id,
+        output_text=output_text,
+        error_message=error_message)
+
+
+async def _stream_session_execute(
+    payload: SessionExecuteRequest,
+    request: Request,
+    settings: ApiGatewaySettings):
+    targets = build_proxy_targets(settings)
+    session_target = targets["session-service"]
+    agent_target = targets["agent-service"]
+    team_target = targets["team-service"]
+    headers = _build_internal_headers(request, settings)
+    client = httpx.AsyncClient(timeout=settings.proxy_timeout_seconds)
+
+    try:
+        session = await _post_json(
+            client=client, target=session_target, path="detail",
+            payload={"session_id": payload.session_id}, headers=headers)
+        target_type = _get_string(session, "runtime_target_type")
+        target_id = _get_string(session, "runtime_target_id")
+        target_config_id = _get_optional_string(session, "runtime_target_config_id")
+        if target_type not in {"agent", "team"} or not target_id:
+            raise HTTPException(status_code=422, detail="session runtime target is not configured")
+
+        run_request_payload = {
+            "target_type": target_type, "target_id": target_id,
+            "target_config_id": target_config_id, "mode": "production",
+            "input_text": payload.message_text,
+        }
+        run_request = await _post_json(
+            client=client, target=session_target, path="run-requests",
+            payload={
+                "session_id": payload.session_id,
+                "app_config_id": target_config_id or target_id,
+                "workflow_config_id": target_id,
+                "trigger_type": "chat",
+                "request_payload_json": run_request_payload,
+                "request_status": "accepted",
+            }, headers=headers)
+        run_request_id = _get_string(run_request, "id")
+
+        user_message = await _post_json(
+            client=client, target=session_target, path="messages",
+            payload={
+                "session_id": payload.session_id, "turn_id": run_request_id,
+                "role": "user", "content_type": "text",
+                "content_text": payload.message_text, "content_json": {},
+            }, headers=headers)
+        user_message_id = _get_string(user_message, "id")
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": "running",
+                "request_payload_json": {**run_request_payload, "user_message_id": user_message_id},
+            }, headers=headers)
+
+        yield _sse("session.execute.started", {
+            "run_request_id": run_request_id, "user_message_id": user_message_id,
+            "target_type": target_type, "target_id": target_id,
+        })
+
+        output_text = ""
+        error_message: str | None = None
+        agent_run_id: str | None = None
+        team_run_id: str | None = None
+
+        if target_type == "agent":
+            agent_run = await _post_json(
+                client=client, target=agent_target, path="runs",
+                payload={
+                    "agent_id": target_id, "agent_config_id": target_config_id,
+                    "session_id": payload.session_id, "input_text": payload.message_text,
+                    "input_json": {"source": "session", "run_request_id": run_request_id},
+                }, headers=headers)
+            agent_run_id = _get_string(agent_run, "id")
+
+            stream_url = _target_url(agent_target, f"runs/{agent_run_id}/execute-stream")
+            async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
+                if not resp.is_success:
+                    error_message = await _read_stream_error(resp)
+                else:
+                    async for ev_name, ev_data in _parse_sse(resp):
+                        data = json.loads(ev_data)
+                        yield _sse(ev_name, data)
+                        if ev_name == "agent.run.delta" and isinstance(data.get("text"), str):
+                            output_text += data["text"]
+                        elif ev_name == "agent.run.completed":
+                            run_data = data.get("run", data)
+                            if not output_text and isinstance(run_data.get("output_text"), str):
+                                output_text = run_data["output_text"]
+                        elif ev_name == "agent.run.failed":
+                            error_message = data.get("error_message", "Agent execution failed")
+                            if not isinstance(error_message, str):
+                                error_message = "Agent execution failed"
+        else:
+            team_run = await _post_json(
+                client=client, target=team_target, path="runs",
+                payload={
+                    "team_id": target_id, "team_config_id": target_config_id,
+                    "session_id": payload.session_id, "input_text": payload.message_text,
+                    "input_json": {"source": "session", "run_request_id": run_request_id},
+                    "enqueue": True,
+                }, headers=headers)
+            team_run_id = _get_string(team_run, "id")
+
+            stream_url = _target_url(team_target, f"runs/{team_run_id}/execute-stream")
+            async with client.stream("POST", stream_url, headers=headers, json={"dry_run": False}) as resp:
+                if not resp.is_success:
+                    error_message = await _read_stream_error(resp)
+                else:
+                    async for ev_name, ev_data in _parse_sse(resp):
+                        data = json.loads(ev_data)
+                        yield _sse(ev_name, data)
+                        if ev_name == "team.run.delta" and isinstance(data.get("text"), str):
+                            output_text += data["text"]
+                        elif ev_name == "team.run.completed":
+                            run_data = data.get("run", data)
+                            if not output_text and isinstance(run_data.get("output_text"), str):
+                                output_text = run_data["output_text"]
+                        elif ev_name == "team.run.failed":
+                            error_message = data.get("error_message", "Team execution failed")
+                            if not isinstance(error_message, str):
+                                error_message = "Team execution failed"
+
+        request_status = "failed" if error_message else "completed"
+        assistant_message_id: str | None = None
+        if output_text:
+            assistant_message = await _post_json(
+                client=client, target=session_target, path="messages",
+                payload={
+                    "session_id": payload.session_id, "turn_id": run_request_id,
+                    "role": "assistant", "content_type": "text",
+                    "content_text": output_text, "content_json": {},
+                }, headers=headers)
+            assistant_message_id = _get_string(assistant_message, "id")
+
+        await _post_json(
+            client=client, target=session_target, path="run-requests/update",
+            payload={
+                "run_request_id": run_request_id, "request_status": request_status,
+                "request_payload_json": {
+                    **run_request_payload, "user_message_id": user_message_id,
+                    "assistant_message_id": assistant_message_id,
+                    "agent_run_id": agent_run_id, "team_run_id": team_run_id,
+                    "output_text": output_text, "error_message": error_message,
+                },
+            }, headers=headers)
+
+        yield _sse("session.execute.completed", {
+            "run_request_id": run_request_id, "request_status": request_status,
+            "assistant_message_id": assistant_message_id, "output_text": output_text,
+            "error_message": error_message,
+        })
+
+    except HTTPException as exc:
+        detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail, ensure_ascii=False)
+        yield _sse("session.execute.failed", {"error_message": detail})
+    except Exception as exc:
+        yield _sse("session.execute.failed", {"error_message": str(exc)})
+    finally:
+        await client.aclose()
+
+
 @router.api_route(
 @router.api_route(
     "/gateway/sessions",
     "/gateway/sessions",
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
     methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -477,3 +868,126 @@ async def proxy_code_runner_service(
         request=request,
         request=request,
         target=build_proxy_targets(settings)["code-runner-service"],
         target=build_proxy_targets(settings)["code-runner-service"],
         path=path)
         path=path)
+
+
+def _build_internal_headers(request: Request, settings: ApiGatewaySettings) -> dict[str, str]:
+    headers = build_internal_service_headers(settings)
+    authorization = request.headers.get("authorization")
+    user_id = request.headers.get("x-user-id")
+    if authorization:
+        headers["authorization"] = authorization
+    if user_id:
+        headers["x-user-id"] = user_id
+    return headers
+
+
+def httpx_client(timeout_seconds: float) -> httpx.AsyncClient:
+    return httpx.AsyncClient(timeout=timeout_seconds)
+
+
+async def _post_json(
+    *,
+    client: httpx.AsyncClient,
+    target: ProxyTarget,
+    path: str,
+    payload: dict[str, object],
+    headers: dict[str, str]) -> dict[str, object]:
+    url = _target_url(target, path)
+    try:
+        response = await client.post(url, headers=headers, json=payload)
+    except httpx.HTTPError as exc:
+        raise HTTPException(status_code=502, detail=f"{target.service_name} request failed: {exc}") from exc
+    if not response.is_success:
+        raise HTTPException(status_code=response.status_code, detail=_error_detail(response))
+    data = response.json()
+    if not isinstance(data, dict):
+        raise HTTPException(status_code=502, detail=f"{target.service_name} returned unexpected response")
+    return data
+
+
+def _target_url(target: ProxyTarget, path: str) -> str:
+    normalized_path = path.strip("/")
+    if normalized_path:
+        return f"{target.base_url.rstrip('/')}{target.path_prefix}/{normalized_path}"
+    return f"{target.base_url.rstrip('/')}{target.path_prefix}"
+
+
+def _error_detail(response: httpx.Response) -> str:
+    try:
+        payload = response.json()
+    except ValueError:
+        return response.text or f"downstream request failed with {response.status_code}"
+    if isinstance(payload, dict):
+        detail = payload.get("detail")
+        if isinstance(detail, str):
+            return detail
+        error = payload.get("error")
+        if isinstance(error, dict):
+            message = error.get("message")
+            if isinstance(message, str):
+                return message
+    return response.text or f"downstream request failed with {response.status_code}"
+
+
+def _get_string(payload: dict[str, object], key: str) -> str:
+    value = payload.get(key)
+    if not isinstance(value, str) or not value:
+        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+    return value
+
+
+def _get_optional_string(payload: dict[str, object], key: str) -> str | None:
+    value = payload.get(key)
+    return value if isinstance(value, str) and value else None
+
+
+def _get_dict(payload: dict[str, object], key: str) -> dict[str, object]:
+    value = payload.get(key)
+    if not isinstance(value, dict):
+        raise HTTPException(status_code=502, detail=f"downstream response missing {key}")
+    return value
+
+
+def _resolve_output_text(run_payload: dict[str, object]) -> str | None:
+    output_text = _get_optional_string(run_payload, "output_text")
+    if output_text:
+        return output_text
+    output_json = run_payload.get("output_json")
+    if isinstance(output_json, dict) and output_json:
+        return json.dumps(output_json, ensure_ascii=False)
+    return None
+
+
+def _sse(event: str, data: dict) -> str:
+    return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
+
+
+async def _parse_sse(response: httpx.Response):
+    current_event = "message"
+    current_data = ""
+    async for line in response.aiter_lines():
+        if line.startswith("event:"):
+            current_event = line[6:].strip()
+        elif line.startswith("data:"):
+            current_data = line[5:].strip()
+        elif line == "":
+            if current_data:
+                yield current_event, current_data
+            current_event = "message"
+            current_data = ""
+    if current_data:
+        yield current_event, current_data
+
+
+async def _read_stream_error(response: httpx.Response) -> str:
+    body = await response.aread()
+    text = body.decode(errors="replace")
+    try:
+        data = json.loads(text)
+        if isinstance(data, dict):
+            detail = data.get("detail")
+            if isinstance(detail, str):
+                return detail
+    except (ValueError, UnicodeDecodeError):
+        pass
+    return text or f"downstream error {response.status_code}"

+ 28 - 0
services/session-service/alembic/versions/20260512_0002_session_runtime_targets.py

@@ -0,0 +1,28 @@
+"""add session runtime target fields
+
+Revision ID: 20260512_0002_session
+Revises: 20260429_9001_session
+Create Date: 2026-05-12 00:00:00.000000
+"""
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260512_0002_session"
+down_revision: str | None = "20260429_9001_session"
+branch_labels: Sequence[str] | None = None
+depends_on: Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column("session", sa.Column("runtime_target_type", sa.String(length=32), nullable=True))
+    op.add_column("session", sa.Column("runtime_target_id", sa.String(length=36), nullable=True))
+    op.add_column("session", sa.Column("runtime_target_config_id", sa.String(length=36), nullable=True))
+
+
+def downgrade() -> None:
+    op.drop_column("session", "runtime_target_config_id")
+    op.drop_column("session", "runtime_target_id")
+    op.drop_column("session", "runtime_target_type")

+ 34 - 2
services/session-service/app/api/routes.py

@@ -1,5 +1,5 @@
 from core_domain import ServiceHealth
 from core_domain import ServiceHealth
-from fastapi import APIRouter, Depends, Query
+from fastapi import APIRouter, Depends, HTTPException, Query
 from sqlalchemy import text
 from sqlalchemy import text
 from sqlalchemy.orm import Session
 from sqlalchemy.orm import Session
 
 
@@ -10,10 +10,12 @@ from app.domain.repositories import MessageRepository, RunRequestRepository, Ses
 from app.schemas.message import MessageCreateRequest, MessageListRequest, MessageResponse
 from app.schemas.message import MessageCreateRequest, MessageListRequest, MessageResponse
 from app.schemas.run_request import (
 from app.schemas.run_request import (
     RunRequestCreateRequest,
     RunRequestCreateRequest,
+    RunRequestDetailRequest,
     RunRequestListRequest,
     RunRequestListRequest,
     RunRequestResponse,
     RunRequestResponse,
+    RunRequestUpdateRequest,
 )
 )
-from app.schemas.session import SessionCreateRequest, SessionListRequest, SessionResponse
+from app.schemas.session import SessionCreateRequest, SessionDetailRequest, SessionListRequest, SessionResponse
 
 
 router = APIRouter()
 router = APIRouter()
 
 
@@ -59,6 +61,16 @@ def list_sessions_post(
     return [SessionResponse.from_entity(item) for item in service.list_sessions(payload.app_id)]
     return [SessionResponse.from_entity(item) for item in service.list_sessions(payload.app_id)]
 
 
 
 
+@router.post("/detail", response_model=SessionResponse)
+def detail_session(
+    payload: SessionDetailRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> SessionResponse:
+    entity = service.get_session(session_id=payload.session_id)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"session not found: {payload.session_id}")
+    return SessionResponse.from_entity(entity)
+
+
 @router.post("/messages", response_model=MessageResponse)
 @router.post("/messages", response_model=MessageResponse)
 def create_message(
 def create_message(
     payload: MessageCreateRequest,
     payload: MessageCreateRequest,
@@ -113,3 +125,23 @@ def list_run_requests_post(
         RunRequestResponse.from_entity(item)
         RunRequestResponse.from_entity(item)
         for item in service.list_run_requests(session_id=payload.session_id)
         for item in service.list_run_requests(session_id=payload.session_id)
     ]
     ]
+
+
+@router.post("/run-requests/detail", response_model=RunRequestResponse)
+def get_run_request(
+    payload: RunRequestDetailRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
+    entity = service.run_request_repository.get_by_id(run_request_id=payload.run_request_id)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+    return RunRequestResponse.from_entity(entity)
+
+
+@router.post("/run-requests/update", response_model=RunRequestResponse)
+def update_run_request(
+    payload: RunRequestUpdateRequest,
+    service: SessionApplicationService = Depends(get_session_application_service)) -> RunRequestResponse:
+    entity = service.update_run_request(payload)
+    if entity is None:
+        raise HTTPException(status_code=404, detail=f"run_request not found: {payload.run_request_id}")
+    return RunRequestResponse.from_entity(entity)

+ 14 - 2
services/session-service/app/application/services.py

@@ -2,7 +2,7 @@ from app.db.models import Message, RunRequest
 from app.db.models import Session as SessionModel
 from app.db.models import Session as SessionModel
 from app.domain.repositories import MessageRepository, RunRequestRepository, SessionRepository
 from app.domain.repositories import MessageRepository, RunRequestRepository, SessionRepository
 from app.schemas.message import MessageCreateRequest
 from app.schemas.message import MessageCreateRequest
-from app.schemas.run_request import RunRequestCreateRequest
+from app.schemas.run_request import RunRequestCreateRequest, RunRequestUpdateRequest
 from app.schemas.session import SessionCreateRequest
 from app.schemas.session import SessionCreateRequest
 
 
 
 
@@ -21,11 +21,17 @@ class SessionApplicationService:
             app_id=payload.app_id,
             app_id=payload.app_id,
             user_id=payload.user_id,
             user_id=payload.user_id,
             channel_type=payload.channel_type,
             channel_type=payload.channel_type,
-            title=payload.title)
+            title=payload.title,
+            runtime_target_type=payload.runtime_target_type,
+            runtime_target_id=payload.runtime_target_id,
+            runtime_target_config_id=payload.runtime_target_config_id)
 
 
     def list_sessions(self, app_id: str | None = None) -> list[SessionModel]:
     def list_sessions(self, app_id: str | None = None) -> list[SessionModel]:
         return self.session_repository.list_by_scope(app_id=app_id)
         return self.session_repository.list_by_scope(app_id=app_id)
 
 
+    def get_session(self, *, session_id: str) -> SessionModel | None:
+        return self.session_repository.get_by_id(session_id=session_id)
+
     def create_message(self, payload: MessageCreateRequest) -> Message:
     def create_message(self, payload: MessageCreateRequest) -> Message:
         return self.message_repository.create(
         return self.message_repository.create(
             session_id=payload.session_id,
             session_id=payload.session_id,
@@ -49,3 +55,9 @@ class SessionApplicationService:
 
 
     def list_run_requests(self, session_id: str) -> list[RunRequest]:
     def list_run_requests(self, session_id: str) -> list[RunRequest]:
         return self.run_request_repository.list_by_session(session_id=session_id)
         return self.run_request_repository.list_by_session(session_id=session_id)
+
+    def update_run_request(self, payload: RunRequestUpdateRequest) -> RunRequest | None:
+        return self.run_request_repository.update(
+            run_request_id=payload.run_request_id,
+            request_payload_json=payload.request_payload_json,
+            request_status=payload.request_status)

+ 3 - 0
services/session-service/app/db/models/session.py

@@ -13,6 +13,9 @@ class Session(EntityMixin, AuditMixin, Base):
     channel_type: Mapped[str] = mapped_column(String(32))
     channel_type: Mapped[str] = mapped_column(String(32))
     session_status: Mapped[str] = mapped_column(String(32), default="active")
     session_status: Mapped[str] = mapped_column(String(32), default="active")
     title: Mapped[str | None] = mapped_column(String(256), nullable=True)
     title: Mapped[str | None] = mapped_column(String(256), nullable=True)
+    runtime_target_type: Mapped[str | None] = mapped_column(String(32), nullable=True)
+    runtime_target_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
+    runtime_target_config_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
     started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     started_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_active_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     last_active_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     closed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
     closed_time: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)

+ 39 - 1
services/session-service/app/domain/repositories.py

@@ -17,12 +17,18 @@ class SessionRepository:
         app_id: str,
         app_id: str,
         user_id: str,
         user_id: str,
         channel_type: str,
         channel_type: str,
-        title: str | None) -> SessionModel:
+        title: str | None,
+        runtime_target_type: str | None,
+        runtime_target_id: str | None,
+        runtime_target_config_id: str | None) -> SessionModel:
         entity = SessionModel(
         entity = SessionModel(
             app_id=app_id,
             app_id=app_id,
             user_id=user_id,
             user_id=user_id,
             channel_type=channel_type,
             channel_type=channel_type,
             title=title,
             title=title,
+            runtime_target_type=runtime_target_type,
+            runtime_target_id=runtime_target_id,
+            runtime_target_config_id=runtime_target_config_id,
             started_time=datetime.utcnow(),
             started_time=datetime.utcnow(),
             last_active_time=datetime.utcnow())
             last_active_time=datetime.utcnow())
         self.db.add(entity)
         self.db.add(entity)
@@ -30,6 +36,10 @@ class SessionRepository:
         self.db.refresh(entity)
         self.db.refresh(entity)
         return entity
         return entity
 
 
+    def get_by_id(self, *, session_id: str) -> SessionModel | None:
+        stmt = select(SessionModel).where(SessionModel.id == session_id)
+        return self.db.scalars(stmt).first()
+
     def list_by_scope(self, *, app_id: str | None = None) -> list[SessionModel]:
     def list_by_scope(self, *, app_id: str | None = None) -> list[SessionModel]:
         stmt = select(SessionModel)
         stmt = select(SessionModel)
         if app_id:
         if app_id:
@@ -57,6 +67,12 @@ class MessageRepository:
             content_type=content_type,
             content_type=content_type,
             content_text=content_text,
             content_text=content_text,
             content_json=content_json)
             content_json=content_json)
+        session = self.db.scalars(
+            select(SessionModel).where(SessionModel.id == session_id)
+        ).first()
+        if session is not None:
+            session.last_active_time = datetime.utcnow()
+            self.db.add(session)
         self.db.add(entity)
         self.db.add(entity)
         self.db.commit()
         self.db.commit()
         self.db.refresh(entity)
         self.db.refresh(entity)
@@ -103,3 +119,25 @@ class RunRequestRepository:
             .order_by(RunRequest.created_time.desc())
             .order_by(RunRequest.created_time.desc())
         )
         )
         return list(self.db.scalars(stmt))
         return list(self.db.scalars(stmt))
+
+    def get_by_id(self, *, run_request_id: str) -> RunRequest | None:
+        stmt = select(RunRequest).where(RunRequest.id == run_request_id)
+        return self.db.scalars(stmt).first()
+
+    def update(
+        self,
+        *,
+        run_request_id: str,
+        request_payload_json: dict | None = None,
+        request_status: str | None = None) -> RunRequest | None:
+        entity = self.get_by_id(run_request_id=run_request_id)
+        if entity is None:
+            return None
+        if request_payload_json is not None:
+            entity.request_payload_json = request_payload_json
+        if request_status is not None:
+            entity.request_status = request_status
+        self.db.add(entity)
+        self.db.commit()
+        self.db.refresh(entity)
+        return entity

+ 10 - 0
services/session-service/app/schemas/run_request.py

@@ -21,6 +21,16 @@ class RunRequestListRequest(BaseModel):
     session_id: str
     session_id: str
 
 
 
 
+class RunRequestDetailRequest(BaseModel):
+    run_request_id: str
+
+
+class RunRequestUpdateRequest(BaseModel):
+    run_request_id: str
+    request_payload_json: dict[str, JSONValue] | None = None
+    request_status: str | None = None
+
+
 class RunRequestResponse(BaseModel):
 class RunRequestResponse(BaseModel):
     id: str
     id: str
     session_id: str
     session_id: str

+ 10 - 0
services/session-service/app/schemas/session.py

@@ -12,12 +12,19 @@ class SessionCreateRequest(BaseModel):
     user_id: str
     user_id: str
     channel_type: str = "web"
     channel_type: str = "web"
     title: str | None = None
     title: str | None = None
+    runtime_target_type: str | None = None
+    runtime_target_id: str | None = None
+    runtime_target_config_id: str | None = None
 
 
 
 
 class SessionListRequest(BaseModel):
 class SessionListRequest(BaseModel):
     app_id: str | None = None
     app_id: str | None = None
 
 
 
 
+class SessionDetailRequest(BaseModel):
+    session_id: str
+
+
 class SessionResponse(BaseModel):
 class SessionResponse(BaseModel):
     id: str
     id: str
     app_id: str
     app_id: str
@@ -25,6 +32,9 @@ class SessionResponse(BaseModel):
     channel_type: str
     channel_type: str
     session_status: str
     session_status: str
     title: str | None = None
     title: str | None = None
+    runtime_target_type: str | None = None
+    runtime_target_id: str | None = None
+    runtime_target_config_id: str | None = None
     started_time: datetime | None = None
     started_time: datetime | None = None
     last_active_time: datetime | None = None
     last_active_time: datetime | None = None
     created_time: datetime
     created_time: datetime

+ 1 - 1
web/index.html

@@ -15,7 +15,7 @@
     </script>
     </script>
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
-    <title>Auto Platform</title>
+    <title>AgentDock</title>
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.googleapis.com" />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
     <link
     <link

+ 87 - 2
web/src/api/sessions.ts

@@ -1,5 +1,7 @@
-import { apiClient } from "./client";
-import type { Message, RunRequest, Session } from "@/types";
+import { apiClient, normalizeGatewayBasePath } from "./client";
+import { useAuthStore } from "@/stores/auth";
+import { useUiStore } from "@/stores/ui";
+import type { Message, RunRequest, Session, SessionExecuteResult } from "@/types";
 
 
 export async function listSessions() {
 export async function listSessions() {
   const { data } = await apiClient.post<Session[]>("/sessions/list", {});
   const { data } = await apiClient.post<Session[]>("/sessions/list", {});
@@ -11,6 +13,9 @@ export async function createSession(payload: {
   user_id: string;
   user_id: string;
   channel_type: string;
   channel_type: string;
   title?: string | null;
   title?: string | null;
+  runtime_target_type?: string | null;
+  runtime_target_id?: string | null;
+  runtime_target_config_id?: string | null;
 }) {
 }) {
   const { data } = await apiClient.post<Session>("/sessions", payload);
   const { data } = await apiClient.post<Session>("/sessions", payload);
   return data;
   return data;
@@ -37,3 +42,83 @@ export async function listRunRequests(sessionId?: string) {
   const { data } = await apiClient.post<RunRequest[]>("/sessions/run-requests/list", { session_id: sessionId });
   const { data } = await apiClient.post<RunRequest[]>("/sessions/run-requests/list", { session_id: sessionId });
   return data;
   return data;
 }
 }
+
+export async function executeSession(payload: {
+  session_id: string;
+  message_text: string;
+  stream?: boolean;
+}) {
+  const { data } = await apiClient.post<SessionExecuteResult>("/sessions/execute", {
+    session_id: payload.session_id,
+    message_text: payload.message_text,
+    stream: payload.stream ?? false,
+  });
+  return data;
+}
+
+export function executeSessionStream(
+  payload: { session_id: string; message_text: string },
+  onEvent: (event: string, data: Record<string, unknown>) => void,
+): AbortController {
+  const controller = new AbortController();
+  const { accessToken, tokenType, userId } = useAuthStore.getState();
+  const baseUrl = normalizeGatewayBasePath(useUiStore.getState().siteSettings.gatewayBasePath);
+
+  const headers: Record<string, string> = {
+    "Content-Type": "application/json",
+    Accept: "text/event-stream",
+  };
+  if (accessToken) headers["Authorization"] = `${tokenType || "bearer"} ${accessToken}`;
+  if (userId) headers["x-user-id"] = userId;
+
+  fetch(`${baseUrl}/sessions/execute`, {
+    method: "POST",
+    headers,
+    body: JSON.stringify({ session_id: payload.session_id, message_text: payload.message_text, stream: true }),
+    signal: controller.signal,
+  }).then(async (response) => {
+    if (!response.ok) {
+      const text = await response.text();
+      try {
+        onEvent("session.execute.failed", { error_message: JSON.parse(text).detail || text });
+      } catch {
+        onEvent("session.execute.failed", { error_message: text || `HTTP ${response.status}` });
+      }
+      return;
+    }
+    const reader = response.body?.getReader();
+    if (!reader) {
+      onEvent("session.execute.failed", { error_message: "Streaming not supported" });
+      return;
+    }
+    const decoder = new TextDecoder();
+    let buffer = "";
+    let currentEvent = "message";
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
+      buffer += decoder.decode(value, { stream: true });
+      const lines = buffer.split("\n");
+      buffer = lines.pop() || "";
+      for (const line of lines) {
+        if (line.startsWith("event:")) {
+          currentEvent = line.slice(6).trim();
+        } else if (line.startsWith("data:")) {
+          const dataStr = line.slice(5).trim();
+          try {
+            onEvent(currentEvent, JSON.parse(dataStr));
+          } catch {
+            onEvent(currentEvent, { raw: dataStr });
+          }
+          currentEvent = "message";
+        }
+      }
+    }
+  }).catch((err) => {
+    if (err.name !== "AbortError") {
+      onEvent("session.execute.failed", { error_message: err.message });
+    }
+  });
+
+  return controller;
+}

+ 1 - 1
web/src/locales/en.json

@@ -113,7 +113,7 @@
     "closeNavigation": "Close navigation"
     "closeNavigation": "Close navigation"
   },
   },
   "app": {
   "app": {
-    "name": "Auto Platform",
+    "name": "AgentDock",
     "loadingStudio": "Loading studio"
     "loadingStudio": "Loading studio"
   },
   },
   "demo": {
   "demo": {

+ 1 - 1
web/src/locales/zh.json

@@ -113,7 +113,7 @@
     "closeNavigation": "关闭导航"
     "closeNavigation": "关闭导航"
   },
   },
   "app": {
   "app": {
-    "name": "Auto Platform",
+    "name": "AgentDock",
     "loadingStudio": "正在加载工作台"
     "loadingStudio": "正在加载工作台"
   },
   },
   "demo": {
   "demo": {

+ 58 - 8
web/src/pages/sessions/SessionChatPage.tsx

@@ -1,7 +1,7 @@
 import * as React from "react";
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
 import { Info, MessageSquarePlus } from "lucide-react";
 import { Info, MessageSquarePlus } from "lucide-react";
-import { createMessage, listMessages, listRunRequests } from "@/api";
+import { executeSessionStream, listMessages, listRunRequests } from "@/api";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { ApiErrorState } from "@/components/shared/ApiErrorState";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { LoadingSpinner } from "@/components/shared/LoadingSpinner";
 import { PageHeader } from "@/components/shared/PageHeader";
 import { PageHeader } from "@/components/shared/PageHeader";
@@ -26,6 +26,8 @@ export function SessionChatPage() {
   const [runRequests, setRunRequests] = React.useState<RunRequest[]>([]);
   const [runRequests, setRunRequests] = React.useState<RunRequest[]>([]);
   const [createOpen, setCreateOpen] = React.useState(false);
   const [createOpen, setCreateOpen] = React.useState(false);
   const [contextOpen, setContextOpen] = React.useState(false);
   const [contextOpen, setContextOpen] = React.useState(false);
+  const [sending, setSending] = React.useState(false);
+  const [streamingText, setStreamingText] = React.useState<string | null>(null);
 
 
   React.useEffect(() => {
   React.useEffect(() => {
     if (!activeSessionId && sessions.data?.[0]) setActiveSessionId(sessions.data[0].id);
     if (!activeSessionId && sessions.data?.[0]) setActiveSessionId(sessions.data[0].id);
@@ -47,13 +49,55 @@ export function SessionChatPage() {
     `${session.title ?? ""} ${session.channel_type} ${session.user_id}`.toLowerCase().includes(search.toLowerCase()));
     `${session.title ?? ""} ${session.channel_type} ${session.user_id}`.toLowerCase().includes(search.toLowerCase()));
   const activeSession = (sessions.data ?? []).find((session) => session.id === activeSessionId);
   const activeSession = (sessions.data ?? []).find((session) => session.id === activeSessionId);
 
 
-  async function send(text: string) {
+  function send(text: string) {
     if (!activeSessionId) return;
     if (!activeSessionId) return;
-    await createMessage({ session_id: activeSessionId, role: "user", content_type: "text", content_text: text });
-    const [nextMessages, nextRuns] = await Promise.all([listMessages(activeSessionId), listRunRequests(activeSessionId)]);
-    setMessages(nextMessages);
-    setRunRequests(nextRuns);
-    toast.success(t("sessions.messageSent"));
+    const sessionId = activeSessionId;
+    setSending(true);
+    setStreamingText("");
+    setMessages((prev) => [...prev, {
+      id: "temp-" + Date.now(),
+      session_id: sessionId,
+      role: "user",
+      content_type: "text",
+      content_text: text,
+      created_time: new Date().toISOString(),
+    }]);
+
+    executeSessionStream(
+      { session_id: sessionId, message_text: text },
+      (event, data) => {
+        if (event === "agent.run.delta" || event === "team.run.delta") {
+          setStreamingText((prev) => (prev ?? "") + (data.text as string || ""));
+        } else if (event === "session.execute.completed") {
+          setStreamingText(null);
+          setSending(false);
+          if (data.request_status === "failed") {
+            toast.error((data.error_message as string) || t("sessions.runFailed", "Run failed"));
+          } else {
+            toast.success(t("sessions.messageSent"));
+          }
+          void Promise.all([
+            listMessages(sessionId),
+            listRunRequests(sessionId),
+            sessions.refetch(),
+          ]).then(([nextMessages, nextRuns]) => {
+            setMessages(nextMessages);
+            setRunRequests(nextRuns);
+          });
+        } else if (event === "session.execute.failed") {
+          setStreamingText(null);
+          setSending(false);
+          toast.error((data.error_message as string) || t("sessions.runFailed", "Run failed"));
+          void Promise.all([
+            listMessages(sessionId),
+            listRunRequests(sessionId),
+          ]).then(([nextMessages, nextRuns]) => {
+            setMessages(nextMessages);
+            setRunRequests(nextRuns);
+          });
+        }
+      },
+    );
   }
   }
 
 
   if (sessions.loading) return <LoadingSpinner label={t("common.loading")} />;
   if (sessions.loading) return <LoadingSpinner label={t("common.loading")} />;
@@ -97,7 +141,7 @@ export function SessionChatPage() {
             </Button>
             </Button>
           </CardHeader>
           </CardHeader>
           <CardContent className="p-0">
           <CardContent className="p-0">
-            <ChatPanel messages={messages} active={Boolean(activeSessionId)} onSend={(text) => void send(text)} />
+            <ChatPanel messages={messages} active={Boolean(activeSessionId)} disabled={sending} streamingText={streamingText} onSend={(text) => send(text)} />
           </CardContent>
           </CardContent>
         </Card>
         </Card>
       </div>
       </div>
@@ -122,6 +166,7 @@ export function SessionChatPage() {
               <ContextItem label={t("sessions.application")} value={appName(apps.data ?? [], activeSession.app_id, t("sessions.unknownApplication"))} />
               <ContextItem label={t("sessions.application")} value={appName(apps.data ?? [], activeSession.app_id, t("sessions.unknownApplication"))} />
               <ContextItem label={t("sessions.channelType")} value={formatChannel(activeSession.channel_type)} />
               <ContextItem label={t("sessions.channelType")} value={formatChannel(activeSession.channel_type)} />
               <ContextItem label={t("sessions.user")} value={activeSession.user_id} />
               <ContextItem label={t("sessions.user")} value={activeSession.user_id} />
+              <ContextItem label={t("sessions.runtimeTarget", "Runtime target")} value={formatTarget(activeSession, t("sessions.notConfigured", "Not configured"))} />
               <ContextItem label={t("sessions.lastActive")} value={formatDateTime(activeSession.last_active_time ?? activeSession.created_time)} />
               <ContextItem label={t("sessions.lastActive")} value={formatDateTime(activeSession.last_active_time ?? activeSession.created_time)} />
               <ContextItem label={t("sessions.messages")} value={String(messages.length)} />
               <ContextItem label={t("sessions.messages")} value={String(messages.length)} />
               <ContextItem label={t("sessions.runActivity")} value={String(runRequests.length)} />
               <ContextItem label={t("sessions.runActivity")} value={String(runRequests.length)} />
@@ -158,6 +203,11 @@ function appName(apps: Array<{ id: string; name: string }>, appId: string, fallb
   return apps.find((app) => app.id === appId)?.name ?? fallback;
   return apps.find((app) => app.id === appId)?.name ?? fallback;
 }
 }
 
 
+function formatTarget(session: Session, fallback: string) {
+  if (!session.runtime_target_type || !session.runtime_target_id) return fallback;
+  return `${formatChannel(session.runtime_target_type)} · ${session.runtime_target_id.slice(0, 8)}`;
+}
+
 function formatChannel(value: string) {
 function formatChannel(value: string) {
   return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
   return value.replace(/[_-]+/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
 }
 }

+ 26 - 3
web/src/pages/sessions/components/ChatPanel.tsx

@@ -8,10 +8,14 @@ import type { Message } from "@/types";
 export function ChatPanel({
 export function ChatPanel({
   messages,
   messages,
   active,
   active,
+  disabled = false,
+  streamingText,
   onSend,
   onSend,
 }: {
 }: {
   messages: Message[];
   messages: Message[];
   active: boolean;
   active: boolean;
+  disabled?: boolean;
+  streamingText?: string | null;
   onSend: (text: string) => void;
   onSend: (text: string) => void;
 }) {
 }) {
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -19,8 +23,11 @@ export function ChatPanel({
     <section className="flex min-h-[560px] min-w-0 flex-1 flex-col overflow-hidden bg-surface-base">
     <section className="flex min-h-[560px] min-w-0 flex-1 flex-col overflow-hidden bg-surface-base">
       <div className="min-w-0 flex-1 space-y-3 overflow-auto p-3 sm:p-4">
       <div className="min-w-0 flex-1 space-y-3 overflow-auto p-3 sm:p-4">
         {active ? (
         {active ? (
-          messages.length ? (
-            messages.map((message) => <MessageBubble key={message.id} message={message} />)
+          messages.length > 0 || streamingText != null ? (
+            <>
+              {messages.map((message) => <MessageBubble key={message.id} message={message} />)}
+              {streamingText != null && <StreamingBubble text={streamingText} />}
+            </>
           ) : (
           ) : (
             <EmptyState icon={MessageCircle} title={t("sessions.noMessages")} description={t("sessions.sendFirstMessage")} />
             <EmptyState icon={MessageCircle} title={t("sessions.noMessages")} description={t("sessions.sendFirstMessage")} />
           )
           )
@@ -28,7 +35,23 @@ export function ChatPanel({
           <EmptyState icon={MessageCircle} title={t("sessions.noSessionSelected")} description={t("sessions.chooseOrCreate")} />
           <EmptyState icon={MessageCircle} title={t("sessions.noSessionSelected")} description={t("sessions.chooseOrCreate")} />
         )}
         )}
       </div>
       </div>
-      <ChatInput disabled={!active} onSend={onSend} />
+      <ChatInput disabled={!active || disabled} onSend={onSend} />
     </section>
     </section>
   );
   );
 }
 }
+
+function StreamingBubble({ text }: { text: string }) {
+  const { t } = useTranslation();
+  return (
+    <div className="flex min-w-0 justify-start">
+      <div className="min-w-0 max-w-[82%] rounded-md border border-border bg-surface-elevated px-3 py-2 text-sm">
+        <div className="mb-0.5 text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
+          {t("sessions.roleAssistant")}
+        </div>
+        <p className="whitespace-pre-wrap break-words leading-6 [overflow-wrap:anywhere]">
+          {text}<span className="animate-pulse">|</span>
+        </p>
+      </div>
+    </div>
+  );
+}

+ 99 - 4
web/src/pages/sessions/components/CreateSessionDialog.tsx

@@ -1,13 +1,13 @@
 import * as React from "react";
 import * as React from "react";
 import { useTranslation } from "react-i18next";
 import { useTranslation } from "react-i18next";
-import { createSession } from "@/api";
+import { createSession, listAgentConfigs, listAgents, listTeamConfigs, listTeams } from "@/api";
 import { Button } from "@/components/ui/button";
 import { Button } from "@/components/ui/button";
 import { Dialog } from "@/components/ui/dialog";
 import { Dialog } from "@/components/ui/dialog";
 import { Input } from "@/components/ui/input";
 import { Input } from "@/components/ui/input";
 import { Select } from "@/components/ui/select";
 import { Select } from "@/components/ui/select";
 import { toast } from "@/components/ui/toaster";
 import { toast } from "@/components/ui/toaster";
 import { useAuthStore } from "@/stores/auth";
 import { useAuthStore } from "@/stores/auth";
-import type { AppResponse, Session } from "@/types";
+import type { AgentConfig, AppResponse, Session, TeamConfig, TeamDefinition } from "@/types";
 
 
 export function CreateSessionDialog({
 export function CreateSessionDialog({
   open,
   open,
@@ -25,17 +25,66 @@ export function CreateSessionDialog({
   const [appId, setAppId] = React.useState("");
   const [appId, setAppId] = React.useState("");
   const [title, setTitle] = React.useState("");
   const [title, setTitle] = React.useState("");
   const [channelType, setChannelType] = React.useState("web");
   const [channelType, setChannelType] = React.useState("web");
+  const [targetType, setTargetType] = React.useState<"agent" | "team">("agent");
+  const [agents, setAgents] = React.useState<Array<{ id: string; name: string }>>([]);
+  const [teams, setTeams] = React.useState<TeamDefinition[]>([]);
+  const [targetId, setTargetId] = React.useState("");
+  const [agentConfigs, setAgentConfigs] = React.useState<AgentConfig[]>([]);
+  const [teamConfigs, setTeamConfigs] = React.useState<TeamConfig[]>([]);
+  const [targetConfigId, setTargetConfigId] = React.useState("");
   const [submitting, setSubmitting] = React.useState(false);
   const [submitting, setSubmitting] = React.useState(false);
 
 
   React.useEffect(() => {
   React.useEffect(() => {
     if (!appId && apps[0]) setAppId(apps[0].id);
     if (!appId && apps[0]) setAppId(apps[0].id);
   }, [appId, apps]);
   }, [appId, apps]);
 
 
+  React.useEffect(() => {
+    if (!open) return;
+    void Promise.all([listAgents(), listTeams()]).then(([nextAgents, nextTeams]) => {
+      setAgents(nextAgents.map((agent) => ({ id: agent.id, name: agent.name })));
+      setTeams(nextTeams);
+      if (!targetId) {
+        if (targetType === "agent" && nextAgents[0]) setTargetId(nextAgents[0].id);
+        if (targetType === "team" && nextTeams[0]) setTargetId(nextTeams[0].id);
+      }
+    });
+  }, [open, targetId, targetType]);
+
+  React.useEffect(() => {
+    if (!targetId) {
+      setAgentConfigs([]);
+      setTeamConfigs([]);
+      setTargetConfigId("");
+      return;
+    }
+    if (targetType === "agent") {
+      void listAgentConfigs(targetId).then((configs) => {
+        setAgentConfigs(configs);
+        setTeamConfigs([]);
+        setTargetConfigId((current) => current || configs[0]?.id || "");
+      });
+      return;
+    }
+    void listTeamConfigs(targetId).then((configs) => {
+      setTeamConfigs(configs);
+      setAgentConfigs([]);
+      setTargetConfigId((current) => current || configs[0]?.id || "");
+    });
+  }, [targetId, targetType]);
+
   async function submit(event: React.FormEvent) {
   async function submit(event: React.FormEvent) {
     event.preventDefault();
     event.preventDefault();
     setSubmitting(true);
     setSubmitting(true);
     try {
     try {
-      const session = await createSession({ user_id: userId, app_id: appId, title: title.trim(), channel_type: channelType });
+      const session = await createSession({
+        user_id: userId,
+        app_id: appId,
+        title: title.trim(),
+        channel_type: channelType,
+        runtime_target_type: targetType,
+        runtime_target_id: targetId,
+        runtime_target_config_id: targetConfigId || null,
+      });
       toast.success(t("sessions.sessionCreated"));
       toast.success(t("sessions.sessionCreated"));
       onCreated(session);
       onCreated(session);
       onOpenChange(false);
       onOpenChange(false);
@@ -72,13 +121,59 @@ export function CreateSessionDialog({
             ]}
             ]}
           />
           />
         </label>
         </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeTarget", "Runtime target")}</span>
+          <Select
+            value={targetType}
+            onChange={(event) => {
+              const nextType = event.target.value as "agent" | "team";
+              setTargetType(nextType);
+              setTargetId("");
+              setTargetConfigId("");
+            }}
+            options={[
+              { value: "agent", label: t("sessions.targetAgent", "Agent") },
+              { value: "team", label: t("sessions.targetTeam", "Team") },
+            ]}
+          />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeTargetItem", "Target")}</span>
+          <Select
+            value={targetId}
+            onChange={(event) => {
+              setTargetId(event.target.value);
+              setTargetConfigId("");
+            }}
+            options={targetType === "agent"
+              ? agents.map((agent) => ({ value: agent.id, label: agent.name }))
+              : teams.map((team) => ({ value: team.id, label: team.name }))}
+          />
+        </label>
+        <label className="block space-y-2 text-sm">
+          <span className="font-medium text-foreground">{t("sessions.runtimeConfig", "Config")}</span>
+          <Select
+            value={targetConfigId}
+            onChange={(event) => setTargetConfigId(event.target.value)}
+            options={[
+              { value: "", label: t("sessions.runtimeConfigLatest", "Latest active config") },
+              ...(targetType === "agent"
+                ? agentConfigs.map((config) => ({ value: config.id, label: formatConfigLabel(config.id, config.role || "assistant") }))
+                : teamConfigs.map((config) => ({ value: config.id, label: formatConfigLabel(config.id, config.coordination_mode || "supervisor") }))),
+            ]}
+          />
+        </label>
         <div className="flex justify-end gap-2">
         <div className="flex justify-end gap-2">
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
           <Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
             {t("common.cancel")}
             {t("common.cancel")}
           </Button>
           </Button>
-          <Button disabled={!appId || submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
+          <Button disabled={!appId || !targetId || submitting}>{submitting ? t("common.creating") : t("common.create")}</Button>
         </div>
         </div>
       </form>
       </form>
     </Dialog>
     </Dialog>
   );
   );
 }
 }
+
+function formatConfigLabel(id: string, secondary: string) {
+  return `${secondary} · ${id.slice(0, 8)}`;
+}

+ 1 - 1
web/src/stores/ui.ts

@@ -15,7 +15,7 @@ export interface SiteSettings {
 }
 }
 
 
 const DEFAULT_SITE_SETTINGS: SiteSettings = {
 const DEFAULT_SITE_SETTINGS: SiteSettings = {
-  workspaceName: "Auto Platform",
+  workspaceName: "AgentDock",
   defaultRoute: "/dashboard",
   defaultRoute: "/dashboard",
   density: "comfortable",
   density: "comfortable",
   dashboardRefreshSeconds: 30,
   dashboardRefreshSeconds: 30,

+ 18 - 0
web/src/types/session.ts

@@ -7,6 +7,9 @@ export interface Session {
   channel_type: string;
   channel_type: string;
   session_status: string;
   session_status: string;
   title?: string | null;
   title?: string | null;
+  runtime_target_type?: string | null;
+  runtime_target_id?: string | null;
+  runtime_target_config_id?: string | null;
   started_time?: string | null;
   started_time?: string | null;
   last_active_time?: string | null;
   last_active_time?: string | null;
   created_time: string;
   created_time: string;
@@ -33,3 +36,18 @@ export interface RunRequest {
   request_status: string;
   request_status: string;
   created_time: string;
   created_time: string;
 }
 }
+
+export interface SessionExecuteResult {
+  session_id: string;
+  run_request_id: string;
+  target_type: string;
+  target_id: string;
+  target_config_id?: string | null;
+  request_status: string;
+  user_message_id: string;
+  assistant_message_id?: string | null;
+  agent_run_id?: string | null;
+  team_run_id?: string | null;
+  output_text?: string | null;
+  error_message?: string | null;
+}