Merge master into ishan/fix-activity-clerkprovider

This commit is contained in:
Omar (OpenClaw)
2026-02-07 11:30:34 +00:00
20 changed files with 3385 additions and 208 deletions

View File

@@ -45,11 +45,41 @@ jobs:
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
run: make setup
- name: Install backend dependencies
run: make backend-sync
- name: Run checks
- name: Install frontend dependencies
run: make frontend-sync
- name: Run backend checks
env:
# Keep CI builds deterministic and secretless.
NEXT_TELEMETRY_DISABLED: "1"
run: make check
run: |
make backend-lint
make backend-typecheck
make backend-coverage
- name: Run frontend checks
env:
# Keep CI builds deterministic.
NEXT_TELEMETRY_DISABLED: "1"
# Clerk env (wired from repo settings; values are not printed).
CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ vars.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
CLERK_JWKS_URL: ${{ vars.CLERK_JWKS_URL }}
run: |
make frontend-lint
make frontend-typecheck
make frontend-test
make frontend-build
- name: Upload coverage artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage
if-no-files-found: ignore
path: |
backend/coverage.xml
frontend/coverage/**

View File

@@ -68,12 +68,20 @@ frontend-typecheck: ## Typecheck frontend (tsc)
cd $(FRONTEND_DIR) && npx tsc -p tsconfig.json --noEmit
.PHONY: test
test: backend-test ## Run tests
test: backend-test frontend-test ## Run tests
.PHONY: backend-test
backend-test: ## Backend tests (pytest)
cd $(BACKEND_DIR) && uv run pytest
.PHONY: backend-coverage
backend-coverage: ## Backend tests with coverage gate (100% stmt + branch on covered src)
cd $(BACKEND_DIR) && uv run pytest --cov=app --cov-branch --cov-report=term-missing --cov-report=xml:coverage.xml
.PHONY: frontend-test
frontend-test: ## Frontend tests (vitest)
cd $(FRONTEND_DIR) && npm run test
.PHONY: backend-migrate
backend-migrate: ## Apply backend DB migrations (alembic upgrade head)
cd $(BACKEND_DIR) && uv run alembic upgrade head
@@ -95,4 +103,4 @@ backend-templates-sync: ## Sync templates to existing gateway agents (usage: mak
cd $(BACKEND_DIR) && uv run python scripts/sync_gateway_templates.py --gateway-id "$(GATEWAY_ID)" $(SYNC_ARGS)
.PHONY: check
check: lint typecheck test build ## Run lint + typecheck + tests + build
check: lint typecheck backend-coverage frontend-test build ## Run lint + typecheck + tests + coverage + build

View File

@@ -5,7 +5,6 @@ from typing import Any, cast
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import func
from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession
@@ -43,13 +42,13 @@ from app.schemas.board_onboarding import BoardOnboardingAgentUpdate, BoardOnboar
from app.schemas.boards import BoardRead
from app.schemas.common import OkResponse
from app.schemas.gateway_coordination import (
GatewayBoardEnsureRequest,
GatewayBoardEnsureResponse,
GatewayLeadBroadcastBoardResult,
GatewayLeadBroadcastRequest,
GatewayLeadBroadcastResponse,
GatewayLeadMessageRequest,
GatewayLeadMessageResponse,
GatewayMainAskUserRequest,
GatewayMainAskUserResponse,
)
from app.schemas.pagination import DefaultLimitOffsetPage
from app.schemas.tasks import TaskCommentCreate, TaskCommentRead, TaskCreate, TaskRead, TaskUpdate
@@ -82,13 +81,6 @@ async def _gateway_config(session: AsyncSession, board: Board) -> GatewayClientC
return GatewayClientConfig(url=gateway.url, token=gateway.token)
def _slugify(value: str) -> str:
import re
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or "board"
async def _require_gateway_main(
session: AsyncSession,
agent: Agent,
@@ -498,87 +490,97 @@ async def agent_heartbeat(
)
@router.post("/gateway/boards/ensure", response_model=GatewayBoardEnsureResponse)
async def ensure_gateway_board(
payload: GatewayBoardEnsureRequest,
@router.post(
"/boards/{board_id}/gateway/main/ask-user",
response_model=GatewayMainAskUserResponse,
)
async def ask_user_via_gateway_main(
payload: GatewayMainAskUserRequest,
board: Board = Depends(get_board_or_404),
session: AsyncSession = Depends(get_session),
agent_ctx: AgentAuthContext = Depends(get_agent_auth_context),
) -> GatewayBoardEnsureResponse:
gateway, config = await _require_gateway_main(session, agent_ctx.agent)
) -> GatewayMainAskUserResponse:
import json
requested_name = payload.name.strip()
requested_slug = _slugify(payload.slug.strip() if payload.slug else requested_name)
_guard_board_access(agent_ctx, board)
if not agent_ctx.agent.is_board_lead:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
# Try slug match first, then case-insensitive name match.
existing = (
await session.exec(
select(Board)
.where(col(Board.gateway_id) == gateway.id)
.where(col(Board.slug) == requested_slug)
if not board.gateway_id:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Board is not attached to a gateway",
)
).first()
if existing is None:
existing = (
await session.exec(
select(Board)
.where(col(Board.gateway_id) == gateway.id)
.where(func.lower(col(Board.name)) == requested_name.lower())
gateway = await session.get(Gateway, board.gateway_id)
if gateway is None or not gateway.url:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway is not configured for this board",
)
).first()
created = False
board = existing
if board is None:
slug = requested_slug
suffix = 2
while True:
conflict = (
await session.exec(
select(Board.id)
.where(col(Board.gateway_id) == gateway.id)
.where(col(Board.slug) == slug)
main_session_key = (gateway.main_session_key or "").strip()
if not main_session_key:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Gateway main session key is required",
)
).first()
if conflict is None:
break
slug = f"{requested_slug}-{suffix}"
suffix += 1
config = GatewayClientConfig(url=gateway.url, token=gateway.token)
board = Board(
name=requested_name,
slug=slug,
gateway_id=gateway.id,
board_type=payload.board_type,
objective=payload.objective.strip() if payload.objective else None,
success_metrics=payload.success_metrics,
target_date=payload.target_date,
goal_confirmed=False,
goal_source="gateway_main_agent",
correlation = payload.correlation_id.strip() if payload.correlation_id else ""
correlation_line = f"Correlation ID: {correlation}\n" if correlation else ""
preferred_channel = (payload.preferred_channel or "").strip()
channel_line = f"Preferred channel: {preferred_channel}\n" if preferred_channel else ""
tags = payload.reply_tags or ["gateway_main", "user_reply"]
tags_json = json.dumps(tags)
reply_source = payload.reply_source or "user_via_gateway_main"
base_url = settings.base_url or "http://localhost:8000"
message = (
"LEAD REQUEST: ASK USER\n"
f"Board: {board.name}\n"
f"Board ID: {board.id}\n"
f"From lead: {agent_ctx.agent.name}\n"
f"{correlation_line}"
f"{channel_line}\n"
f"{payload.content.strip()}\n\n"
"Please reach the user via your configured OpenClaw channel(s) (Slack/SMS/etc).\n"
"If you cannot reach them there, post the question in Mission Control board chat as a fallback.\n\n"
"When you receive the answer, reply in Mission Control by writing a NON-chat memory item on this board:\n"
f"POST {base_url}/api/v1/agent/boards/{board.id}/memory\n"
f'Body: {{"content":"<answer>","tags":{tags_json},"source":"{reply_source}"}}\n'
"Do NOT reply in OpenClaw chat."
)
session.add(board)
await session.commit()
await session.refresh(board)
created = True
lead, lead_created = await ensure_board_lead_agent(
try:
await ensure_session(main_session_key, config=config, label="Main Agent")
await send_message(message, session_key=main_session_key, config=config, deliver=True)
except OpenClawGatewayError as exc:
record_activity(
session,
board=board,
gateway=gateway,
config=config,
user=None,
agent_name=payload.lead_agent_name.strip() if payload.lead_agent_name else None,
identity_profile=payload.lead_identity_profile,
action="provision",
event_type="gateway.lead.ask_user.failed",
message=f"Lead user question failed for {board.name}: {exc}",
agent_id=agent_ctx.agent.id,
)
await session.commit()
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
record_activity(
session,
event_type="gateway.lead.ask_user.sent",
message=f"Lead requested user info via gateway main for board: {board.name}.",
agent_id=agent_ctx.agent.id,
)
return GatewayBoardEnsureResponse(
created=created,
lead_created=lead_created,
main_agent = (
await session.exec(select(Agent).where(col(Agent.openclaw_session_id) == main_session_key))
).first()
await session.commit()
return GatewayMainAskUserResponse(
board_id=board.id,
lead_agent_id=lead.id,
board_name=board.name,
board_slug=board.slug,
lead_agent_name=lead.name,
main_agent_id=main_agent.id if main_agent else None,
main_agent_name=main_agent.name if main_agent else None,
)

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
@@ -9,29 +8,6 @@ from sqlmodel import Field, SQLModel
from app.schemas.common import NonEmptyStr
class GatewayBoardEnsureRequest(SQLModel):
name: NonEmptyStr
slug: str | None = None
board_type: Literal["goal", "general"] = "goal"
objective: str | None = None
success_metrics: dict[str, object] | None = None
target_date: datetime | None = None
lead_agent_name: str | None = None
lead_identity_profile: dict[str, str] | None = None
class GatewayBoardEnsureResponse(SQLModel):
created: bool = False
lead_created: bool = False
board_id: UUID
lead_agent_id: UUID | None = None
# Convenience fields for callers that don't want to re-fetch.
board_name: str
board_slug: str
lead_agent_name: str | None = None
class GatewayLeadMessageRequest(SQLModel):
kind: Literal["question", "handoff"] = "question"
correlation_id: str | None = None
@@ -72,3 +48,20 @@ class GatewayLeadBroadcastResponse(SQLModel):
sent: int = 0
failed: int = 0
results: list[GatewayLeadBroadcastBoardResult] = Field(default_factory=list)
class GatewayMainAskUserRequest(SQLModel):
correlation_id: str | None = None
content: NonEmptyStr
preferred_channel: str | None = None
# How the main agent should reply back into Mission Control (defaults interpreted by templates).
reply_tags: list[str] = Field(default_factory=lambda: ["gateway_main", "user_reply"])
reply_source: str | None = "user_via_gateway_main"
class GatewayMainAskUserResponse(SQLModel):
ok: bool = True
board_id: UUID
main_agent_id: UUID | None = None
main_agent_name: str | None = None

View File

@@ -86,6 +86,49 @@ def _slugify(value: str) -> str:
return slug or uuid4().hex
def _agent_id_from_session_key(session_key: str | None) -> str | None:
value = (session_key or "").strip()
if not value:
return None
if not value.startswith("agent:"):
return None
parts = value.split(":")
if len(parts) < 2:
return None
agent_id = parts[1].strip()
return agent_id or None
def _extract_agent_id(payload: object) -> str | None:
def _from_list(items: object) -> str | None:
if not isinstance(items, list):
return None
for item in items:
if isinstance(item, str) and item.strip():
return item.strip()
if not isinstance(item, dict):
continue
for key in ("id", "agentId", "agent_id"):
raw = item.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
return None
if isinstance(payload, list):
return _from_list(payload)
if not isinstance(payload, dict):
return None
for key in ("defaultId", "default_id", "defaultAgentId", "default_agent_id"):
raw = payload.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
for key in ("agents", "items", "list", "data"):
agent_id = _from_list(payload.get(key))
if agent_id:
return agent_id
return None
def _agent_key(agent: Agent) -> str:
session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"):
@@ -383,24 +426,18 @@ def _render_agent_files(
async def _gateway_default_agent_id(
config: GatewayClientConfig,
*,
fallback_session_key: str | None = None,
) -> str | None:
try:
payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError:
return None
if not isinstance(payload, dict):
return None
default_id = payload.get("defaultId") or payload.get("default_id")
if isinstance(default_id, str) and default_id:
return default_id
agents = payload.get("agents") or []
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
agent_id = first.get("id")
if isinstance(agent_id, str) and agent_id:
return _agent_id_from_session_key(fallback_session_key)
agent_id = _extract_agent_id(payload)
if agent_id:
return agent_id
return None
return _agent_id_from_session_key(fallback_session_key)
async def _patch_gateway_agent_list(
@@ -585,7 +622,10 @@ async def provision_main_agent(
client_config = GatewayClientConfig(url=gateway.url, token=gateway.token)
await ensure_session(gateway.main_session_key, config=client_config, label="Main Agent")
agent_id = await _gateway_default_agent_id(client_config)
agent_id = await _gateway_default_agent_id(
client_config,
fallback_session_key=gateway.main_session_key,
)
if not agent_id:
raise OpenClawGatewayError("Unable to resolve gateway main agent id")

View File

@@ -1,6 +1,9 @@
from __future__ import annotations
import asyncio
import re
from collections.abc import Awaitable, Callable
from typing import TypeVar
from uuid import UUID, uuid4
from sqlmodel import col, select
@@ -19,12 +22,94 @@ from app.services.agent_provisioning import provision_agent, provision_main_agen
_TOOLS_KV_RE = re.compile(r"^(?P<key>[A-Z0-9_]+)=(?P<value>.*)$")
T = TypeVar("T")
def _slugify(value: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
return slug or uuid4().hex
def _is_transient_gateway_error(exc: Exception) -> bool:
if not isinstance(exc, OpenClawGatewayError):
return False
message = str(exc).lower()
if not message:
return False
if "unsupported file" in message:
return False
if "received 1012" in message or "service restart" in message:
return True
if "http 503" in message or ("503" in message and "websocket" in message):
return True
if "temporar" in message:
return True
if "timeout" in message or "timed out" in message:
return True
if "connection closed" in message or "connection reset" in message:
return True
return False
async def _with_gateway_retry(
fn: Callable[[], Awaitable[T]],
*,
attempts: int = 3,
base_delay_s: float = 0.75,
) -> T:
for attempt in range(attempts):
try:
return await fn()
except Exception as exc:
if attempt >= attempts - 1 or not _is_transient_gateway_error(exc):
raise
await asyncio.sleep(base_delay_s * (2**attempt))
raise AssertionError("unreachable")
def _agent_id_from_session_key(session_key: str | None) -> str | None:
value = (session_key or "").strip()
if not value:
return None
if not value.startswith("agent:"):
return None
parts = value.split(":")
if len(parts) < 2:
return None
agent_id = parts[1].strip()
return agent_id or None
def _extract_agent_id(payload: object) -> str | None:
def _from_list(items: object) -> str | None:
if not isinstance(items, list):
return None
for item in items:
if isinstance(item, str) and item.strip():
return item.strip()
if not isinstance(item, dict):
continue
for key in ("id", "agentId", "agent_id"):
raw = item.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
return None
if isinstance(payload, list):
return _from_list(payload)
if not isinstance(payload, dict):
return None
for key in ("defaultId", "default_id", "defaultAgentId", "default_agent_id"):
raw = payload.get(key)
if isinstance(raw, str) and raw.strip():
return raw.strip()
for key in ("agents", "items", "list", "data"):
agent_id = _from_list(payload.get(key))
if agent_id:
return agent_id
return None
def _gateway_agent_id(agent: Agent) -> str:
session_key = agent.openclaw_session_id or ""
if session_key.startswith("agent:"):
@@ -94,24 +179,34 @@ async def _get_existing_auth_token(
return token or None
async def _gateway_default_agent_id(config: GatewayClientConfig) -> str | None:
async def _gateway_default_agent_id(
config: GatewayClientConfig,
*,
fallback_session_key: str | None = None,
) -> str | None:
last_error: OpenClawGatewayError | None = None
# Gateways may reject WS connects transiently under load (HTTP 503).
for attempt in range(3):
try:
payload = await openclaw_call("agents.list", config=config)
except OpenClawGatewayError:
return None
if not isinstance(payload, dict):
return None
default_id = payload.get("defaultId") or payload.get("default_id")
if isinstance(default_id, str) and default_id:
return default_id
agents = payload.get("agents") or []
if isinstance(agents, list) and agents:
first = agents[0]
if isinstance(first, dict):
agent_id = first.get("id")
if isinstance(agent_id, str) and agent_id:
agent_id = _extract_agent_id(payload)
if agent_id:
return agent_id
return None
break
except OpenClawGatewayError as exc:
last_error = exc
message = str(exc).lower()
if (
"503" not in message
and "temporar" not in message
and "rejected" not in message
and "timeout" not in message
):
break
await asyncio.sleep(0.5 * (2**attempt))
_ = last_error
return _agent_id_from_session_key(fallback_session_key)
async def sync_gateway_templates(
@@ -226,6 +321,7 @@ async def sync_gateway_templates(
)
try:
async def _do_provision() -> None:
await provision_agent(
agent,
board,
@@ -236,6 +332,8 @@ async def sync_gateway_templates(
force_bootstrap=force_bootstrap,
reset_session=reset_sessions,
)
await _with_gateway_retry(_do_provision)
result.agents_updated += 1
except Exception as exc: # pragma: no cover - gateway/network dependent
result.agents_skipped += 1
@@ -262,7 +360,10 @@ async def sync_gateway_templates(
)
return result
main_gateway_agent_id = await _gateway_default_agent_id(client_config)
main_gateway_agent_id = await _gateway_default_agent_id(
client_config,
fallback_session_key=gateway.main_session_key,
)
if not main_gateway_agent_id:
result.errors.append(
GatewayTemplatesSyncError(
@@ -277,6 +378,15 @@ async def sync_gateway_templates(
agent_gateway_id=main_gateway_agent_id, config=client_config
)
if not main_token:
if rotate_tokens:
raw_token = generate_agent_token()
main_agent.agent_token_hash = hash_agent_token(raw_token)
main_agent.updated_at = utcnow()
session.add(main_agent)
await session.commit()
await session.refresh(main_agent)
main_token = raw_token
else:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=main_agent.id,
@@ -286,7 +396,28 @@ async def sync_gateway_templates(
)
return result
if main_agent.agent_token_hash and not verify_agent_token(
main_token, main_agent.agent_token_hash
):
if rotate_tokens:
raw_token = generate_agent_token()
main_agent.agent_token_hash = hash_agent_token(raw_token)
main_agent.updated_at = utcnow()
session.add(main_agent)
await session.commit()
await session.refresh(main_agent)
main_token = raw_token
else:
result.errors.append(
GatewayTemplatesSyncError(
agent_id=main_agent.id,
agent_name=main_agent.name,
message="Warning: AUTH_TOKEN in TOOLS.md does not match backend token hash (main agent auth may be broken).",
)
)
try:
async def _do_provision_main() -> None:
await provision_main_agent(
main_agent,
gateway,
@@ -296,6 +427,8 @@ async def sync_gateway_templates(
force_bootstrap=force_bootstrap,
reset_session=reset_sessions,
)
await _with_gateway_retry(_do_provision_main)
result.main_updated = True
except Exception as exc: # pragma: no cover - gateway/network dependent
result.errors.append(

View File

@@ -38,6 +38,8 @@ dev = [
"mypy==1.11.2",
"pytest==8.3.3",
"pytest-asyncio==0.24.0",
"pytest-cov==6.0.0",
"coverage[toml]==7.6.10",
"ruff==0.6.9",
]
@@ -51,3 +53,17 @@ show_error_codes = true
[tool.pytest.ini_options]
asyncio_default_fixture_loop_scope = "function"
[tool.coverage.run]
branch = true
source = ["app"]
# Migrations are generated artifacts; testing them doesn't add coverage signal.
omit = ["alembic/versions/*"]
[tool.coverage.report]
show_missing = true
skip_covered = true
# NOTE: Current baseline coverage is low; we publish coverage.xml in CI but
# don't fail the build until the test suite expands. Tighten toward 100% in
# follow-up PRs as coverage grows.
fail_under = 0

View File

@@ -1,13 +1,18 @@
from __future__ import annotations
import asyncio
import sys
from pathlib import Path
from uuid import uuid4
from app.db.session import async_session_maker, init_db
from app.models.agents import Agent
from app.models.boards import Board
from app.models.gateways import Gateway
from app.models.users import User
BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT))
from app.db.session import async_session_maker, init_db # noqa: E402
from app.models.agents import Agent # noqa: E402
from app.models.boards import Board # noqa: E402
from app.models.gateways import Gateway # noqa: E402
from app.models.users import User # noqa: E402
async def run() -> None:

View File

@@ -2,11 +2,16 @@ from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
from uuid import UUID
from app.db.session import async_session_maker
from app.models.gateways import Gateway
from app.services.template_sync import sync_gateway_templates
BACKEND_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(BACKEND_ROOT))
from app.db.session import async_session_maker # noqa: E402
from app.models.gateways import Gateway # noqa: E402
from app.services.template_sync import sync_gateway_templates # noqa: E402
def _parse_args() -> argparse.Namespace:

55
backend/uv.lock generated
View File

@@ -158,6 +158,44 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.6.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/84/ba/ac14d281f80aab516275012e8875991bb06203957aa1e19950139238d658/coverage-7.6.10.tar.gz", hash = "sha256:7fb105327c8f8f0682e29843e2ff96af9dcbe5bab8eeb4b398c6a33a16d80a23", size = 803868, upload-time = "2024-12-26T16:59:18.734Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/77/19d09ea06f92fdf0487499283b1b7af06bc422ea94534c8fe3a4cd023641/coverage-7.6.10-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:27c6e64726b307782fa5cbe531e7647aee385a29b2107cd87ba7c0105a5d3853", size = 208281, upload-time = "2024-12-26T16:57:42.968Z" },
{ url = "https://files.pythonhosted.org/packages/b6/67/5479b9f2f99fcfb49c0d5cf61912a5255ef80b6e80a3cddba39c38146cf4/coverage-7.6.10-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c56e097019e72c373bae32d946ecf9858fda841e48d82df7e81c63ac25554078", size = 208514, upload-time = "2024-12-26T16:57:45.747Z" },
{ url = "https://files.pythonhosted.org/packages/15/d1/febf59030ce1c83b7331c3546d7317e5120c5966471727aa7ac157729c4b/coverage-7.6.10-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7827a5bc7bdb197b9e066cdf650b2887597ad124dd99777332776f7b7c7d0d0", size = 241537, upload-time = "2024-12-26T16:57:48.647Z" },
{ url = "https://files.pythonhosted.org/packages/4b/7e/5ac4c90192130e7cf8b63153fe620c8bfd9068f89a6d9b5f26f1550f7a26/coverage-7.6.10-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:204a8238afe787323a8b47d8be4df89772d5c1e4651b9ffa808552bdf20e1d50", size = 238572, upload-time = "2024-12-26T16:57:51.668Z" },
{ url = "https://files.pythonhosted.org/packages/dc/03/0334a79b26ecf59958f2fe9dd1f5ab3e2f88db876f5071933de39af09647/coverage-7.6.10-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67926f51821b8e9deb6426ff3164870976fe414d033ad90ea75e7ed0c2e5022", size = 240639, upload-time = "2024-12-26T16:57:53.175Z" },
{ url = "https://files.pythonhosted.org/packages/d7/45/8a707f23c202208d7b286d78ad6233f50dcf929319b664b6cc18a03c1aae/coverage-7.6.10-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e78b270eadb5702938c3dbe9367f878249b5ef9a2fcc5360ac7bff694310d17b", size = 240072, upload-time = "2024-12-26T16:57:56.087Z" },
{ url = "https://files.pythonhosted.org/packages/66/02/603ce0ac2d02bc7b393279ef618940b4a0535b0868ee791140bda9ecfa40/coverage-7.6.10-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:714f942b9c15c3a7a5fe6876ce30af831c2ad4ce902410b7466b662358c852c0", size = 238386, upload-time = "2024-12-26T16:57:57.572Z" },
{ url = "https://files.pythonhosted.org/packages/04/62/4e6887e9be060f5d18f1dd58c2838b2d9646faf353232dec4e2d4b1c8644/coverage-7.6.10-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:abb02e2f5a3187b2ac4cd46b8ced85a0858230b577ccb2c62c81482ca7d18852", size = 240054, upload-time = "2024-12-26T16:57:58.967Z" },
{ url = "https://files.pythonhosted.org/packages/5c/74/83ae4151c170d8bd071924f212add22a0e62a7fe2b149edf016aeecad17c/coverage-7.6.10-cp312-cp312-win32.whl", hash = "sha256:55b201b97286cf61f5e76063f9e2a1d8d2972fc2fcfd2c1272530172fd28c359", size = 210904, upload-time = "2024-12-26T16:58:00.688Z" },
{ url = "https://files.pythonhosted.org/packages/c3/54/de0893186a221478f5880283119fc40483bc460b27c4c71d1b8bba3474b9/coverage-7.6.10-cp312-cp312-win_amd64.whl", hash = "sha256:e4ae5ac5e0d1e4edfc9b4b57b4cbecd5bc266a6915c500f358817a8496739247", size = 211692, upload-time = "2024-12-26T16:58:02.35Z" },
{ url = "https://files.pythonhosted.org/packages/25/6d/31883d78865529257bf847df5789e2ae80e99de8a460c3453dbfbe0db069/coverage-7.6.10-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05fca8ba6a87aabdd2d30d0b6c838b50510b56cdcfc604d40760dae7153b73d9", size = 208308, upload-time = "2024-12-26T16:58:04.487Z" },
{ url = "https://files.pythonhosted.org/packages/70/22/3f2b129cc08de00c83b0ad6252e034320946abfc3e4235c009e57cfeee05/coverage-7.6.10-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9e80eba8801c386f72e0712a0453431259c45c3249f0009aff537a517b52942b", size = 208565, upload-time = "2024-12-26T16:58:06.774Z" },
{ url = "https://files.pythonhosted.org/packages/97/0a/d89bc2d1cc61d3a8dfe9e9d75217b2be85f6c73ebf1b9e3c2f4e797f4531/coverage-7.6.10-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a372c89c939d57abe09e08c0578c1d212e7a678135d53aa16eec4430adc5e690", size = 241083, upload-time = "2024-12-26T16:58:10.27Z" },
{ url = "https://files.pythonhosted.org/packages/4c/81/6d64b88a00c7a7aaed3a657b8eaa0931f37a6395fcef61e53ff742b49c97/coverage-7.6.10-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ec22b5e7fe7a0fa8509181c4aac1db48f3dd4d3a566131b313d1efc102892c18", size = 238235, upload-time = "2024-12-26T16:58:12.497Z" },
{ url = "https://files.pythonhosted.org/packages/9a/0b/7797d4193f5adb4b837207ed87fecf5fc38f7cc612b369a8e8e12d9fa114/coverage-7.6.10-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:26bcf5c4df41cad1b19c84af71c22cbc9ea9a547fc973f1f2cc9a290002c8b3c", size = 240220, upload-time = "2024-12-26T16:58:15.619Z" },
{ url = "https://files.pythonhosted.org/packages/65/4d/6f83ca1bddcf8e51bf8ff71572f39a1c73c34cf50e752a952c34f24d0a60/coverage-7.6.10-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4e4630c26b6084c9b3cb53b15bd488f30ceb50b73c35c5ad7871b869cb7365fd", size = 239847, upload-time = "2024-12-26T16:58:17.126Z" },
{ url = "https://files.pythonhosted.org/packages/30/9d/2470df6aa146aff4c65fee0f87f58d2164a67533c771c9cc12ffcdb865d5/coverage-7.6.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2396e8116db77789f819d2bc8a7e200232b7a282c66e0ae2d2cd84581a89757e", size = 237922, upload-time = "2024-12-26T16:58:20.198Z" },
{ url = "https://files.pythonhosted.org/packages/08/dd/723fef5d901e6a89f2507094db66c091449c8ba03272861eaefa773ad95c/coverage-7.6.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:79109c70cc0882e4d2d002fe69a24aa504dec0cc17169b3c7f41a1d341a73694", size = 239783, upload-time = "2024-12-26T16:58:23.614Z" },
{ url = "https://files.pythonhosted.org/packages/3d/f7/64d3298b2baf261cb35466000628706ce20a82d42faf9b771af447cd2b76/coverage-7.6.10-cp313-cp313-win32.whl", hash = "sha256:9e1747bab246d6ff2c4f28b4d186b205adced9f7bd9dc362051cc37c4a0c7bd6", size = 210965, upload-time = "2024-12-26T16:58:26.765Z" },
{ url = "https://files.pythonhosted.org/packages/d5/58/ec43499a7fc681212fe7742fe90b2bc361cdb72e3181ace1604247a5b24d/coverage-7.6.10-cp313-cp313-win_amd64.whl", hash = "sha256:254f1a3b1eef5f7ed23ef265eaa89c65c8c5b6b257327c149db1ca9d4a35f25e", size = 211719, upload-time = "2024-12-26T16:58:28.781Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c9/f2857a135bcff4330c1e90e7d03446b036b2363d4ad37eb5e3a47bbac8a6/coverage-7.6.10-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2ccf240eb719789cedbb9fd1338055de2761088202a9a0b73032857e53f612fe", size = 209050, upload-time = "2024-12-26T16:58:31.616Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b3/f840e5bd777d8433caa9e4a1eb20503495709f697341ac1a8ee6a3c906ad/coverage-7.6.10-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0c807ca74d5a5e64427c8805de15b9ca140bba13572d6d74e262f46f50b13273", size = 209321, upload-time = "2024-12-26T16:58:34.509Z" },
{ url = "https://files.pythonhosted.org/packages/85/7d/125a5362180fcc1c03d91850fc020f3831d5cda09319522bcfa6b2b70be7/coverage-7.6.10-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2bcfa46d7709b5a7ffe089075799b902020b62e7ee56ebaed2f4bdac04c508d8", size = 252039, upload-time = "2024-12-26T16:58:36.072Z" },
{ url = "https://files.pythonhosted.org/packages/a9/9c/4358bf3c74baf1f9bddd2baf3756b54c07f2cfd2535f0a47f1e7757e54b3/coverage-7.6.10-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4e0de1e902669dccbf80b0415fb6b43d27edca2fbd48c74da378923b05316098", size = 247758, upload-time = "2024-12-26T16:58:39.458Z" },
{ url = "https://files.pythonhosted.org/packages/cf/c7/de3eb6fc5263b26fab5cda3de7a0f80e317597a4bad4781859f72885f300/coverage-7.6.10-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7b444c42bbc533aaae6b5a2166fd1a797cdb5eb58ee51a92bee1eb94a1e1cb", size = 250119, upload-time = "2024-12-26T16:58:41.018Z" },
{ url = "https://files.pythonhosted.org/packages/3e/e6/43de91f8ba2ec9140c6a4af1102141712949903dc732cf739167cfa7a3bc/coverage-7.6.10-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b330368cb99ef72fcd2dc3ed260adf67b31499584dc8a20225e85bfe6f6cfed0", size = 249597, upload-time = "2024-12-26T16:58:42.827Z" },
{ url = "https://files.pythonhosted.org/packages/08/40/61158b5499aa2adf9e37bc6d0117e8f6788625b283d51e7e0c53cf340530/coverage-7.6.10-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:9a7cfb50515f87f7ed30bc882f68812fd98bc2852957df69f3003d22a2aa0abf", size = 247473, upload-time = "2024-12-26T16:58:44.486Z" },
{ url = "https://files.pythonhosted.org/packages/50/69/b3f2416725621e9f112e74e8470793d5b5995f146f596f133678a633b77e/coverage-7.6.10-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f93531882a5f68c28090f901b1d135de61b56331bba82028489bc51bdd818d2", size = 248737, upload-time = "2024-12-26T16:58:45.919Z" },
{ url = "https://files.pythonhosted.org/packages/3c/6e/fe899fb937657db6df31cc3e61c6968cb56d36d7326361847440a430152e/coverage-7.6.10-cp313-cp313t-win32.whl", hash = "sha256:89d76815a26197c858f53c7f6a656686ec392b25991f9e409bcef020cd532312", size = 211611, upload-time = "2024-12-26T16:58:47.883Z" },
{ url = "https://files.pythonhosted.org/packages/1c/55/52f5e66142a9d7bc93a15192eba7a78513d2abf6b3558d77b4ca32f5f424/coverage-7.6.10-cp313-cp313t-win_amd64.whl", hash = "sha256:54a5f0f43950a36312155dae55c505a76cd7f2b12d26abeebbe7a0b36dbc868d", size = 212781, upload-time = "2024-12-26T16:58:50.822Z" },
]
[[package]]
name = "cryptography"
version = "44.0.1"
@@ -535,12 +573,14 @@ dependencies = [
[package.optional-dependencies]
dev = [
{ name = "black" },
{ name = "coverage" },
{ name = "flake8" },
{ name = "httpx" },
{ name = "isort" },
{ name = "mypy" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
@@ -548,6 +588,7 @@ dev = [
requires-dist = [
{ name = "alembic", specifier = "==1.13.2" },
{ name = "black", marker = "extra == 'dev'", specifier = "==24.10.0" },
{ name = "coverage", extras = ["toml"], marker = "extra == 'dev'", specifier = "==7.6.10" },
{ name = "fastapi", specifier = "==0.128.0" },
{ name = "fastapi-clerk-auth", specifier = "==0.0.9" },
{ name = "fastapi-pagination", specifier = "==0.15.9" },
@@ -560,6 +601,7 @@ requires-dist = [
{ name = "pydantic-settings", specifier = "==2.5.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.3" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==0.24.0" },
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = "==6.0.0" },
{ name = "python-dotenv", specifier = "==1.0.1" },
{ name = "redis", specifier = "==5.1.1" },
{ name = "rq", specifier = "==1.16.2" },
@@ -828,6 +870,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/96/31/6607dab48616902f76885dfcf62c08d929796fc3b2d2318faf9fd54dbed9/pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b", size = 18024, upload-time = "2024-08-22T08:03:15.536Z" },
]
[[package]]
name = "pytest-cov"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/45/9b538de8cef30e17c7b45ef42f538a94889ed6a16f2387a6c89e73220651/pytest-cov-6.0.0.tar.gz", hash = "sha256:fde0b595ca248bb8e2d76f020b465f3b107c9632e6a1d1705f17834c89dcadc0", size = 66945, upload-time = "2024-10-29T20:13:35.363Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/36/3b/48e79f2cd6a61dbbd4807b4ed46cb564b4fd50a76166b1c4ea5c1d9e2371/pytest_cov-6.0.0-py3-none-any.whl", hash = "sha256:eee6f1b9e61008bd34975a4d5bab25801eb31898b032dd55addc93e96fcaaa35", size = 22949, upload-time = "2024-10-29T20:13:33.215Z" },
]
[[package]]
name = "python-dotenv"
version = "1.0.1"

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest run --passWithNoTests --coverage",
"test:watch": "vitest",
"dev:lan": "next dev --hostname 0.0.0.0 --port 3000",
"api:gen": "orval --config ./orval.config.ts"
},
@@ -29,14 +31,19 @@
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitest/coverage-v8": "^2.1.8",
"autoprefixer": "^10.4.24",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^25.0.1",
"lucide-react": "^0.563.0",
"orval": "^8.2.0",
"postcss": "^8.5.6",
@@ -44,6 +51,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^3.4.17",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5"
"typescript": "^5",
"vitest": "^2.1.8"
}
}

View File

@@ -342,6 +342,7 @@ export default function BoardDetailPage() {
const [isCommentsLoading, setIsCommentsLoading] = useState(false);
const [commentsError, setCommentsError] = useState<string | null>(null);
const [newComment, setNewComment] = useState("");
const taskCommentInputRef = useRef<HTMLTextAreaElement | null>(null);
const [isPostingComment, setIsPostingComment] = useState(false);
const [postCommentError, setPostCommentError] = useState<string | null>(null);
const [isDetailOpen, setIsDetailOpen] = useState(false);
@@ -1713,6 +1714,7 @@ export default function BoardDetailPage() {
);
} finally {
setIsPostingComment(false);
taskCommentInputRef.current?.focus();
}
};
@@ -2612,8 +2614,18 @@ export default function BoardDetailPage() {
</p>
<div className="space-y-2 rounded-xl border border-slate-200 bg-slate-50 p-3">
<Textarea
ref={taskCommentInputRef}
value={newComment}
onChange={(event) => setNewComment(event.target.value)}
onKeyDown={(event) => {
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
if (isPostingComment) return;
if (!newComment.trim()) return;
void handlePostComment();
}}
placeholder="Write a message for the assigned agent…"
className="min-h-[80px] bg-white"
/>

View File

@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { RefreshCcw } from "lucide-react";
import {
DialogFooter,
@@ -120,16 +121,40 @@ export function BoardOnboardingChat({
const isPageActive = usePageActive();
const [session, setSession] = useState<BoardOnboardingRead | null>(null);
const [loading, setLoading] = useState(false);
const [awaitingAssistantFingerprint, setAwaitingAssistantFingerprint] =
useState<string | null>(null);
const [awaitingKind, setAwaitingKind] = useState<
"answer" | "extra_context" | null
>(null);
const [lastSubmittedAnswer, setLastSubmittedAnswer] = useState<string | null>(
null,
);
const [otherText, setOtherText] = useState("");
const [extraContext, setExtraContext] = useState("");
const [extraContextOpen, setExtraContextOpen] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedOptions, setSelectedOptions] = useState<string[]>([]);
const freeTextRef = useRef<HTMLTextAreaElement | null>(null);
const extraContextRef = useRef<HTMLTextAreaElement | null>(null);
const normalizedMessages = useMemo(
() => normalizeMessages(session?.messages),
[session?.messages],
);
const lastAssistantFingerprint = useMemo(() => {
const rawMessages = session?.messages;
if (!rawMessages || !Array.isArray(rawMessages)) return "";
for (let idx = rawMessages.length - 1; idx >= 0; idx -= 1) {
const entry = rawMessages[idx];
if (!entry || typeof entry !== "object") continue;
const raw = entry as Record<string, unknown>;
if (raw.role !== "assistant") continue;
const content = typeof raw.content === "string" ? raw.content : "";
const timestamp = typeof raw.timestamp === "string" ? raw.timestamp : "";
return `${timestamp}|${content}`;
}
return "";
}, [session?.messages]);
const question = useMemo(
() => parseQuestion(normalizedMessages),
[normalizedMessages],
@@ -137,11 +162,26 @@ export function BoardOnboardingChat({
const draft: BoardOnboardingAgentComplete | null =
session?.draft_goal ?? null;
const isAwaitingAgent = useMemo(() => {
if (!awaitingAssistantFingerprint) return false;
return lastAssistantFingerprint === awaitingAssistantFingerprint;
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const wantsFreeText = useMemo(
() => selectedOptions.some((label) => isFreeTextOption(label)),
[selectedOptions],
);
useEffect(() => {
if (!wantsFreeText) return;
freeTextRef.current?.focus();
}, [wantsFreeText]);
useEffect(() => {
if (!extraContextOpen) return;
extraContextRef.current?.focus();
}, [extraContextOpen]);
useEffect(() => {
setSelectedOptions([]);
setOtherText("");
@@ -194,8 +234,12 @@ export function BoardOnboardingChat({
const handleAnswer = useCallback(
async (value: string, freeText?: string) => {
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true);
setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try {
const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(
@@ -208,6 +252,12 @@ export function BoardOnboardingChat({
if (result.status !== 200) throw new Error("Unable to submit answer.");
setSession(result.data);
setOtherText("");
setSelectedOptions([]);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("answer");
setLastSubmittedAnswer(
freeText ? `${value}: ${freeText}` : value,
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to submit answer.",
@@ -216,7 +266,7 @@ export function BoardOnboardingChat({
setLoading(false);
}
},
[boardId],
[boardId, lastAssistantFingerprint],
);
const toggleOption = useCallback((label: string) => {
@@ -230,8 +280,12 @@ export function BoardOnboardingChat({
const submitExtraContext = useCallback(async () => {
const trimmed = extraContext.trim();
if (!trimmed) return;
const fingerprintBefore = lastAssistantFingerprint;
setLoading(true);
setError(null);
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
try {
const result =
await answerOnboardingApiV1BoardsBoardIdOnboardingAnswerPost(boardId, {
@@ -242,6 +296,10 @@ export function BoardOnboardingChat({
throw new Error("Unable to submit extra context.");
setSession(result.data);
setExtraContext("");
setExtraContextOpen(false);
setAwaitingAssistantFingerprint(fingerprintBefore);
setAwaitingKind("extra_context");
setLastSubmittedAnswer("Additional context");
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to submit extra context.",
@@ -249,7 +307,7 @@ export function BoardOnboardingChat({
} finally {
setLoading(false);
}
}, [boardId, extraContext]);
}, [boardId, extraContext, lastAssistantFingerprint]);
const submitAnswer = useCallback(() => {
const trimmedOther = otherText.trim();
@@ -259,6 +317,15 @@ export function BoardOnboardingChat({
void handleAnswer(answer, wantsFreeText ? trimmedOther : undefined);
}, [handleAnswer, otherText, selectedOptions, wantsFreeText]);
useEffect(() => {
if (!awaitingAssistantFingerprint) return;
if (lastAssistantFingerprint !== awaitingAssistantFingerprint) {
setAwaitingAssistantFingerprint(null);
setAwaitingKind(null);
setLastSubmittedAnswer(null);
}
}, [awaitingAssistantFingerprint, lastAssistantFingerprint]);
const confirmGoal = async () => {
if (!draft) return;
setLoading(true);
@@ -303,6 +370,29 @@ export function BoardOnboardingChat({
<p className="text-sm text-slate-600">
Review the lead agent draft and confirm.
</p>
{isAwaitingAgent ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div className="flex items-center gap-2 font-medium text-slate-900">
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
<span>
{awaitingKind === "extra_context"
? "Updating the draft…"
: "Waiting for the agent…"}
</span>
</div>
{lastSubmittedAnswer ? (
<p className="mt-2 text-xs text-slate-600">
Sent:{" "}
<span className="font-medium text-slate-900">
{lastSubmittedAnswer}
</span>
</p>
) : null}
<p className="mt-1 text-xs text-slate-500">
This usually takes a few seconds.
</p>
</div>
) : null}
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 text-sm">
<p className="font-semibold text-slate-900">Objective</p>
<p className="text-slate-700">{draft.objective || "—"}</p>
@@ -402,7 +492,7 @@ export function BoardOnboardingChat({
size="sm"
type="button"
onClick={() => setExtraContextOpen((prev) => !prev)}
disabled={loading}
disabled={loading || isAwaitingAgent}
>
{extraContextOpen ? "Hide" : "Add"}
</Button>
@@ -410,17 +500,20 @@ export function BoardOnboardingChat({
{extraContextOpen ? (
<div className="mt-2 space-y-2">
<Textarea
ref={extraContextRef}
className="min-h-[84px]"
placeholder="Anything else the agent should know before you confirm? (constraints, context, preferences, links, etc.)"
value={extraContext}
onChange={(event) => setExtraContext(event.target.value)}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
if (loading) return;
if (loading || isAwaitingAgent) return;
void submitExtraContext();
}}
disabled={loading || isAwaitingAgent}
/>
<div className="flex items-center justify-end">
<Button
@@ -428,13 +521,13 @@ export function BoardOnboardingChat({
size="sm"
type="button"
onClick={() => void submitExtraContext()}
disabled={loading || !extraContext.trim()}
disabled={loading || isAwaitingAgent || !extraContext.trim()}
>
{loading ? "Sending..." : "Send context"}
{loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Send context"}
</Button>
</div>
<p className="text-xs text-slate-500">
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
Tip: press Enter to send. Shift+Enter for a newline.
</p>
</div>
) : (
@@ -445,7 +538,7 @@ export function BoardOnboardingChat({
)}
</div>
<DialogFooter>
<Button onClick={confirmGoal} disabled={loading}>
<Button onClick={confirmGoal} disabled={loading || isAwaitingAgent} type="button">
Confirm goal
</Button>
</DialogFooter>
@@ -455,6 +548,29 @@ export function BoardOnboardingChat({
<p className="text-sm font-medium text-slate-900">
{question.question}
</p>
{isAwaitingAgent ? (
<div className="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">
<div className="flex items-center gap-2 font-medium text-slate-900">
<RefreshCcw className="h-4 w-4 animate-spin text-slate-500" />
<span>
{awaitingKind === "extra_context"
? "Updating the draft…"
: "Waiting for the next question…"}
</span>
</div>
{lastSubmittedAnswer ? (
<p className="mt-2 text-xs text-slate-600">
Sent:{" "}
<span className="font-medium text-slate-900">
{lastSubmittedAnswer}
</span>
</p>
) : null}
<p className="mt-1 text-xs text-slate-500">
This usually takes a few seconds.
</p>
</div>
) : null}
<div className="space-y-2">
{question.options.map((option) => {
const isSelected = selectedOptions.includes(option.label);
@@ -464,7 +580,8 @@ export function BoardOnboardingChat({
variant={isSelected ? "primary" : "secondary"}
className="w-full justify-start"
onClick={() => toggleOption(option.label)}
disabled={loading}
disabled={loading || isAwaitingAgent}
type="button"
>
{option.label}
</Button>
@@ -474,20 +591,23 @@ export function BoardOnboardingChat({
{wantsFreeText ? (
<div className="space-y-2">
<Textarea
ref={freeTextRef}
className="min-h-[84px]"
placeholder="Type your answer..."
value={otherText}
onChange={(event) => setOtherText(event.target.value)}
onKeyDown={(event) => {
if (!(event.ctrlKey || event.metaKey)) return;
if (event.key !== "Enter") return;
if (event.nativeEvent.isComposing) return;
if (event.shiftKey) return;
event.preventDefault();
if (loading) return;
if (loading || isAwaitingAgent) return;
submitAnswer();
}}
disabled={loading || isAwaitingAgent}
/>
<p className="text-xs text-slate-500">
Tip: press Ctrl+Enter (or Cmd+Enter) to send.
Tip: press Enter to send. Shift+Enter for a newline.
</p>
</div>
) : null}
@@ -495,16 +615,22 @@ export function BoardOnboardingChat({
<Button
variant="outline"
onClick={submitAnswer}
type="button"
disabled={
loading ||
isAwaitingAgent ||
selectedOptions.length === 0 ||
(wantsFreeText && !otherText.trim())
}
>
{loading ? "Sending..." : "Next"}
{loading ? "Sending..." : isAwaitingAgent ? "Waiting..." : "Next"}
</Button>
{loading ? (
<p className="text-xs text-slate-500">Sending your answer</p>
) : isAwaitingAgent ? (
<p className="text-xs text-slate-500">
Waiting for the agent to respond
</p>
) : null}
</div>
</div>

View File

@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { createExponentialBackoff } from "./backoff";
describe("createExponentialBackoff", () => {
it("increments attempt and clamps delay", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
const backoff = createExponentialBackoff({ baseMs: 100, factor: 2, maxMs: 250, jitter: 0 });
expect(backoff.attempt()).toBe(0);
expect(backoff.nextDelayMs()).toBe(100);
expect(backoff.attempt()).toBe(1);
expect(backoff.nextDelayMs()).toBe(200);
expect(backoff.nextDelayMs()).toBe(250); // capped
});
it("reset brings attempt back to zero", () => {
vi.spyOn(Math, "random").mockReturnValue(0);
const backoff = createExponentialBackoff({ baseMs: 100, jitter: 0 });
backoff.nextDelayMs();
expect(backoff.attempt()).toBe(1);
backoff.reset();
expect(backoff.attempt()).toBe(0);
});
});

View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

20
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "jsdom",
setupFiles: ["./src/setupTests.ts"],
globals: true,
coverage: {
provider: "v8",
reporter: ["text", "lcov"],
reportsDirectory: "./coverage",
include: ["src/**/*.{ts,tsx}"],
exclude: [
"**/*.d.ts",
"src/**/__generated__/**",
"src/**/generated/**",
],
},
},
});

View File

@@ -66,6 +66,12 @@ Comment template (keep it small; 1-3 bullets per section; omit what is not appli
- Board chat is your primary channel with the human; respond promptly and clearly.
- If someone asks for clarity by tagging `@lead`, respond with a crisp decision, delegation, or next action to unblock them.
## Request user input via gateway main (OpenClaw channels)
- If you need information from the human but they are not responding in Mission Control board chat, ask the gateway main agent to reach them via OpenClaw's configured channel(s) (Slack/Telegram/SMS/etc).
- POST `$BASE_URL/api/v1/agent/boards/$BOARD_ID/gateway/main/ask-user`
- Body: `{"content":"<question>","correlation_id":"<optional>","preferred_channel":"<optional>"}`
- The gateway main will post the user's answer back to this board as a NON-chat memory item tagged like `["gateway_main","user_reply"]`.
## Gateway main requests
- If you receive a message starting with `GATEWAY MAIN`, treat it as high priority.
- Do **not** reply in OpenClaw chat. Reply via Mission Control only.

View File

@@ -29,19 +29,17 @@ Do this immediately. Do not ask permission to read your workspace.
## Gateway Delegation (board leads)
- You can message any board lead agent via Mission Control API (never OpenClaw chat).
- If the requested board does not exist, you must create it and provision its lead agent first.
- You cannot create boards. If the requested board does not exist, ask the human/admin to create it in Mission Control, then continue once you have the `board_id`.
- If the human asks a question: ask the relevant board lead(s), then consolidate their answers into one response.
- If the human asks to get work done: hand off the request to the correct board lead (the lead will create tasks and delegate to board agents).
Ensure (create if needed) a board + lead:
List boards (to find `board_id`):
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/ensure" \
curl -s -X GET "$BASE_URL/api/v1/agent/boards" \
-H "X-Agent-Token: $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"<Board Name>","slug":"<optional-slug>","board_type":"goal","objective":"<optional>","success_metrics":null,"target_date":null}'
```
Send a question or handoff to a board lead:
Send a question or handoff to a board lead (auto-provisions the lead agent if missing):
```bash
curl -s -X POST "$BASE_URL/api/v1/agent/gateway/boards/<BOARD_ID>/lead/message" \
-H "X-Agent-Token: $AUTH_TOKEN" \
@@ -62,6 +60,11 @@ Board lead replies:
- Read replies via:
- GET `$BASE_URL/api/v1/agent/boards/<BOARD_ID>/memory?is_chat=false&limit=50`
## User outreach requests (from board leads)
- If you receive a message starting with `LEAD REQUEST: ASK USER`, a board lead needs human input but cannot reach them in Mission Control.
- Use OpenClaw's configured channel(s) to reach the user (Slack/Telegram/SMS/etc). If that fails, post the question into Mission Control board chat as a fallback.
- When you receive the user's answer, write it back to the originating board as a NON-chat memory item tagged like `["gateway_main","user_reply"]` (the exact POST + tags will be included in the request message).
## Tools
- Skills are authoritative. Follow SKILL.md instructions exactly.
- Use TOOLS.md for environment-specific notes.

View File

@@ -19,6 +19,7 @@ If any required input is missing, stop and request a provisioning update.
## Schedule
- If a heartbeat schedule is configured, send a lightweight checkin only.
- Do not claim or move board tasks unless explicitly instructed by Mission Control.
- If you have any pending `LEAD REQUEST: ASK USER` messages in OpenClaw chat, handle them promptly (see MAIN_AGENTS.md).
## Heartbeat checklist
1) Check in: