diff --git a/agents/nemo-studio-copilot/Dockerfile b/agents/nemo-studio-copilot/Dockerfile index e62cbacd19..c725a5b968 100644 --- a/agents/nemo-studio-copilot/Dockerfile +++ b/agents/nemo-studio-copilot/Dockerfile @@ -55,6 +55,12 @@ LABEL org.opencontainers.image.title="nemo-studio-copilot" \ ENV NAT_CONFIG_FILE=/workspace/src/nemo_studio_copilot/nemo-studio-copilot.yml +# Authenticated Kubernetes deployments inject a loopback auth-proxy sidecar on +# this port. NMP_BASE_URL is supplied by the deployment runtime, so use the +# agent-specific legacy override for SDK calls and allow local runtimes to +# replace it when needed. +ENV NEMO_BASE_URL=https://127.0.0.1:8090 + ENV PATH="/workspace/.venv/bin:$PATH" # Some modern base images (notably Ubuntu 24.04 "noble" and the NVIDIA base diff --git a/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py b/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py index 46439b566d..d54aa3fc92 100644 --- a/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py +++ b/agents/nemo-studio-copilot/src/nemo_studio_copilot/register.py @@ -221,7 +221,7 @@ def _delete_fileset(name: str) -> str: def _get_client() -> NeMoPlatform: global _client if _client is None: - base_url = os.environ.get("NMP_BASE_URL") or os.environ.get("NEMO_BASE_URL") + base_url = os.environ.get("NEMO_BASE_URL") or os.environ.get("NMP_BASE_URL") kwargs: dict[str, Any] = {} if base_url: kwargs["base_url"] = base_url diff --git a/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py index 8933fa8fad..6d91ed3840 100644 --- a/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py +++ b/agents/nemo-studio-copilot/tests/test_nemo_studio_copilot.py @@ -184,6 +184,22 @@ def test_sdk_uses_deployment_platform_base_url(self, monkeypatch): workspace="developer-workspace", ) + def test_sdk_prefers_agent_base_url_override(self, monkeypatch): + monkeypatch.setenv("NMP_BASE_URL", "http://platform-gateway:8080") + monkeypatch.setenv("NEMO_BASE_URL", "http://127.0.0.1:8090") + monkeypatch.delenv("NMP_WORKSPACE", raising=False) + + with ( + patch("nemo_studio_copilot.register._client", None), + patch("nemo_studio_copilot.register.NeMoPlatform") as platform_client, + ): + _get_client() + + platform_client.assert_called_once_with( + base_url="http://127.0.0.1:8090", + workspace="default", + ) + def test_sdk_defaults_to_default_workspace(self, monkeypatch): monkeypatch.delenv("NMP_WORKSPACE", raising=False) diff --git a/services/studio/src/nmp/studio/copilot.py b/services/studio/src/nmp/studio/copilot.py index 969077be97..86ec839d82 100644 --- a/services/studio/src/nmp/studio/copilot.py +++ b/services/studio/src/nmp/studio/copilot.py @@ -11,7 +11,6 @@ import os import re import shutil -import time import uuid from collections.abc import AsyncIterator, Awaitable, Mapping from dataclasses import dataclass @@ -21,9 +20,11 @@ from urllib.parse import quote, urlencode, urlparse import httpx -from fastapi import APIRouter, FastAPI, HTTPException, Request +from fastapi import APIRouter, Depends, FastAPI, HTTPException, Request from fastapi.responses import JSONResponse, Response, StreamingResponse +from nmp.common.entities.client import EntityClient, EntityConflictError, EntityNotFoundError, EntityStoreError from nmp.common.entities.constants import NAME_PATTERN +from nmp.common.service.dependencies import get_entity_client from nmp.studio import studio_links from nmp.studio.copilot_artifacts import ( ChatArtifactsResponse, @@ -53,6 +54,7 @@ permission_prompt_tool, ) from nmp.studio.copilot_skills import ClaudeSkillResponse, DuplicateSkillError, list_claude_skill_responses +from nmp.studio.entities import CopilotConversation, CopilotMessage from pydantic import BaseModel, ConfigDict, Field from starlette.routing import NoMatchFound @@ -115,7 +117,7 @@ class AgentInputDecision(BaseModel): class HistorySessionResponse(BaseModel): - """Summary of a Claude session stored on disk.""" + """Summary of a persisted Copilot or legacy Claude session.""" session_id: str mtime: float @@ -129,7 +131,7 @@ class HistorySessionResponse(BaseModel): class SessionHistoryResponse(BaseModel): - """Claude session history normalized for Studio chat replay.""" + """Copilot session history normalized for Studio chat replay.""" session_id: str items: list[dict[str, Any]] @@ -140,44 +142,34 @@ class SessionHistoryResponse(BaseModel): _session_streams: dict[str, asyncio.Queue[tuple[str, Any]]] = {} _pending_permissions: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {} _pending_agent_inputs: dict[str, tuple[str, asyncio.Future[dict[str, Any]]]] = {} -_session_conversations: dict[str, list[dict[str, str]]] = {} -_session_mtimes: dict[str, float] = {} # Cache of Entity-Store-confirmed workspace names, keyed by session then by # (caller fingerprint, requested workspace), so the membership lookup runs once per # session/caller/workspace rather than on every message. Session ids are not bound to # a caller, so the caller's credential participates in the key: a cached authorization -# decision must never be reused for a different caller. Cleared on session eviction. +# decision must never be reused for a different caller. _session_workspace_cache: dict[str, dict[tuple[str, str], str]] = {} _AGENT_INPUT_RESPONSE_RESERVED_KEYS = frozenset({"message", "status"}) -def _evict_oldest_sessions(*, protected_session_ids: set[str] | None = None) -> None: - """Evict least-recently-updated inactive sessions from in-memory history.""" - protected = (protected_session_ids or set()) | set(_session_streams) - while len(_session_conversations) > MAX_RETAINED_SESSIONS: - candidates = set(_session_conversations) - protected - if not candidates: - break - oldest_session_id = min( - candidates, - key=lambda session_id: (_session_mtimes.get(session_id, 0), session_id), - ) - _session_conversations.pop(oldest_session_id, None) - _session_mtimes.pop(oldest_session_id, None) - _initialized_sessions.discard(oldest_session_id) - _session_workspace_cache.pop(oldest_session_id, None) - - for session_id in set(_session_mtimes) - set(_session_conversations): - _session_mtimes.pop(session_id, None) - for session_id in set(_session_workspace_cache) - set(_session_conversations): - _session_workspace_cache.pop(session_id, None) +def _recent_conversation_messages(conversation: list[CopilotMessage]) -> list[CopilotMessage]: + """Bound model context without truncating the persisted chat history.""" + max_messages = MAX_RETAINED_TURNS_PER_SESSION * 2 + return conversation[-max_messages:] -def _retain_recent_turns(conversation: list[dict[str, str]]) -> None: - """Keep only the most recent complete user/assistant turns.""" - max_messages = MAX_RETAINED_TURNS_PER_SESSION * 2 - if len(conversation) > max_messages: - del conversation[:-max_messages] +def _append_conversation_turn( + conversation: CopilotConversation, + user_message: str, + assistant_message: str, + model: str, +) -> None: + conversation.messages.extend( + [ + CopilotMessage(role="user", content=user_message), + CopilotMessage(role="assistant", content=assistant_message), + ] + ) + record_copilot_model(conversation.chat_artifacts, model) @dataclass @@ -232,6 +224,40 @@ def _validate_session_id(session_id: str) -> str: raise HTTPException(status_code=400, detail="session_id must be a UUID") from exc +def _conversation_name(session_id: str) -> str: + """Return the Entity Store name for a Studio session UUID.""" + return f"copilot-{session_id}" + + +def _request_principal_id(request: Request) -> str: + """Return the end-user principal, including service-on-behalf-of requests.""" + return ( + request.headers.get("x-nmp-principal-on-behalf-of") or request.headers.get("x-nmp-principal-id") or "local-user" + ) + + +async def _get_owned_conversation( + entity_store: EntityClient, + *, + session_id: str, + workspace: str, + owner_id: str, +) -> CopilotConversation: + """Load a conversation and enforce per-user ownership within a workspace.""" + try: + conversation = await entity_store.get( + CopilotConversation, + _conversation_name(session_id), + workspace=workspace, + ) + except EntityNotFoundError as exc: + raise HTTPException(status_code=404, detail="no such session history") from exc + if conversation.owner_id != owner_id: + # Do not reveal whether another user's conversation exists. + raise HTTPException(status_code=404, detail="no such session history") + return conversation + + def _trimmed_string(value: Any) -> str | None: if not isinstance(value, str): return None @@ -695,36 +721,59 @@ def _extract_assistant_parts(content: Any) -> list[dict[str, Any]]: @router.post("/sessions", response_model=NewSessionResponse) -def create_session() -> NewSessionResponse: - """Create a new local copilot session.""" +async def create_session( + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> NewSessionResponse: + """Create a durable, user-owned Copilot session.""" + workspace = _validated_workspace_or_default(workspace) session_id = str(uuid.uuid4()) - _session_conversations[session_id] = [] - _session_mtimes[session_id] = time.time() - _evict_oldest_sessions(protected_session_ids={session_id}) + await entity_store.create( + CopilotConversation( + name=_conversation_name(session_id), + workspace=workspace, + session_id=session_id, + owner_id=_request_principal_id(request), + ) + ) return NewSessionResponse(session_id=session_id) @router.get("/history/sessions", response_model=list[HistorySessionResponse]) -def list_history_sessions() -> list[HistorySessionResponse]: - """List active retained NeMo Copilot sessions.""" - _evict_oldest_sessions() +async def list_history_sessions( + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> list[HistorySessionResponse]: + """List the current user's durable NeMo Copilot sessions.""" + workspace = _validated_workspace_or_default(workspace) + owner_id = _request_principal_id(request) + result = await entity_store.list( + CopilotConversation, + workspace=workspace, + filter_obj={"owner_id": owner_id}, + sort="-updated_at", + page_size=MAX_RETAINED_SESSIONS, + ) sessions: list[HistorySessionResponse] = [] - for session_id, messages in _session_conversations.items(): - user_messages = [item["content"] for item in messages if item.get("role") == "user"] + for conversation in result.data: + user_messages = [message.content for message in conversation.messages if message.role == "user"] if not user_messages: continue first_prompt = user_messages[0] + modified_at = conversation.updated_at or conversation.created_at sessions.append( HistorySessionResponse( - session_id=session_id, - mtime=_session_mtimes.get(session_id, 0), + session_id=conversation.session_id, + mtime=modified_at.timestamp() if modified_at else 0, title=first_prompt.splitlines()[0][:80], first_prompt=first_prompt, message_count=len(user_messages), token_count=0, tool_call_count=0, tool_calls=[], - chat_artifacts=ChatArtifactsResponse(), + chat_artifacts=conversation.chat_artifacts, ) ) sessions.sort(key=lambda session: session.mtime, reverse=True) @@ -806,27 +855,41 @@ def _history_user_interaction_texts( @router.get("/history/sessions/{session_id}", response_model=SessionHistoryResponse) -def get_session_history(session_id: str) -> SessionHistoryResponse: +async def get_session_history( + session_id: str, + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> SessionHistoryResponse: """Load a NeMo Copilot session or legacy Claude history for replay.""" sid = _validate_session_id(session_id) - conversation = _session_conversations.get(sid) + workspace = _validated_workspace_or_default(workspace) + try: + conversation = await entity_store.get( + CopilotConversation, + _conversation_name(sid), + workspace=workspace, + ) + except EntityNotFoundError: + conversation = None if conversation is not None: + if conversation.owner_id != _request_principal_id(request): + raise HTTPException(status_code=404, detail="no such session history") items: list[dict[str, Any]] = [] - for message in conversation: - if message.get("role") == "user": - items.append({"kind": "user", "text": message["content"]}) - elif message.get("role") == "assistant": + for message in conversation.messages: + if message.role == "user": + items.append({"kind": "user", "text": message.content}) + elif message.role == "assistant": items.append( { "kind": "assistant", - "parts": [{"type": "text", "text": message["content"]}], + "parts": [{"type": "text", "text": message.content}], } ) - _initialized_sessions.add(sid) return SessionHistoryResponse( session_id=sid, items=items, - chat_artifacts=ChatArtifactsResponse(), + chat_artifacts=conversation.chat_artifacts, ) path = _project_history_dir() / f"{sid}.jsonl" @@ -881,6 +944,39 @@ def get_session_history(session_id: str) -> SessionHistoryResponse: return SessionHistoryResponse(session_id=sid, items=items, chat_artifacts=summary.chat_artifacts) +@router.delete("/history/sessions/{session_id}", status_code=204) +async def delete_session_history( + session_id: str, + request: Request, + workspace: str = "default", + entity_store: EntityClient = Depends(get_entity_client), +) -> Response: + """Delete the current user's persisted Copilot conversation.""" + sid = _validate_session_id(session_id) + workspace = _validated_workspace_or_default(workspace) + if sid in _session_streams: + raise HTTPException(status_code=409, detail="cannot delete a session while it is running") + conversation = await _get_owned_conversation( + entity_store, + session_id=sid, + workspace=workspace, + owner_id=_request_principal_id(request), + ) + try: + await entity_store.delete( + CopilotConversation, + conversation.name, + workspace=workspace, + expected_db_version=conversation.db_version, + ) + except EntityNotFoundError as exc: + raise HTTPException(status_code=404, detail="no such session history") from exc + except EntityConflictError as exc: + raise HTTPException(status_code=409, detail="session changed; refresh history and try again") from exc + _session_workspace_cache.pop(sid, None) + return Response(status_code=204) + + @router.get("/skills", response_model=list[ClaudeSkillResponse]) def list_claude_skills() -> list[ClaudeSkillResponse]: """List NeMo skills that the repo's Claude Code installer exposes.""" @@ -1537,6 +1633,8 @@ async def _stream_copilot( agent_url: str, headers: Mapping[str, str], studio_system_prompt: str, + conversation: CopilotConversation, + entity_store: EntityClient, ) -> AsyncIterator[str]: """Invoke the deployed NeMo Copilot while preserving Studio's blocking UI event protocol.""" if session_id in _session_streams: @@ -1548,7 +1646,6 @@ async def _stream_copilot( queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue() _session_streams[session_id] = queue - conversation = _session_conversations.setdefault(session_id, []) contextual_message = "\n\n".join( [ "", @@ -1558,7 +1655,10 @@ async def _stream_copilot( message, ] ) - request_messages = [*conversation, {"role": "user", "content": contextual_message}] + request_messages = [ + *(persisted_message.model_dump() for persisted_message in _recent_conversation_messages(conversation.messages)), + {"role": "user", "content": contextual_message}, + ] invocation = asyncio.create_task( _invoke_copilot( agent_url, @@ -1604,15 +1704,18 @@ async def _stream_copilot( yield rendered assistant_text, model = await invocation - conversation.extend( - [ - {"role": "user", "content": message}, - {"role": "assistant", "content": assistant_text}, - ] - ) - _retain_recent_turns(conversation) - _session_mtimes[session_id] = time.time() - _initialized_sessions.add(session_id) + _append_conversation_turn(conversation, message, assistant_text, model) + try: + await entity_store.update(conversation) + except EntityConflictError: + logger.info("Reloading conflicted NeMo Copilot session %s before retrying", session_id) + latest_conversation = await entity_store.get( + CopilotConversation, + conversation.name, + workspace=conversation.workspace, + ) + _append_conversation_turn(latest_conversation, message, assistant_text, model) + await entity_store.update(latest_conversation) yield _sse( json.dumps( { @@ -1635,17 +1738,27 @@ async def _stream_copilot( json.dumps({"message": _copilot_error_detail(exc)}), event="error", ) + except EntityStoreError: + logger.exception("Failed to persist NeMo Copilot session %s", session_id) + yield _sse( + json.dumps({"message": "NeMo Copilot could not save this conversation."}), + event="error", + ) finally: _session_streams.pop(session_id, None) if not invocation.done(): invocation.cancel() if not queued_event.done(): queued_event.cancel() - _evict_oldest_sessions() @router.post("/sessions/{session_id}/messages") -async def send_message(session_id: str, body: MessageRequest, request: Request) -> StreamingResponse: +async def send_message( + session_id: str, + body: MessageRequest, + request: Request, + entity_store: EntityClient = Depends(get_entity_client), +) -> StreamingResponse: """Send a message to the deployed NeMo Copilot and stream Studio events.""" sid = _validate_session_id(session_id) workspace = _validated_workspace_or_default(body.workspace) @@ -1654,6 +1767,12 @@ async def send_message(session_id: str, body: MessageRequest, request: Request) # confirmed is equivalent -- and the authorization lookup needs them. agent_headers = _copilot_request_headers(request, _studio_copilot_url(workspace)) canonical_workspace = await _authorized_workspace(workspace, agent_headers, sid) + conversation = await _get_owned_conversation( + entity_store, + session_id=sid, + workspace=canonical_workspace, + owner_id=_request_principal_id(request), + ) agent_url = _studio_copilot_url(canonical_workspace) studio_base_url = _studio_base_url_from_request(body, request) studio_pathname = _studio_pathname_from_request(body, request) @@ -1672,6 +1791,8 @@ async def send_message(session_id: str, body: MessageRequest, request: Request) agent_url, agent_headers, system_prompt, + conversation, + entity_store, ), media_type="text/event-stream", headers={"Cache-Control": "no-store"}, diff --git a/services/studio/src/nmp/studio/entities.py b/services/studio/src/nmp/studio/entities.py new file mode 100644 index 0000000000..42dd14b450 --- /dev/null +++ b/services/studio/src/nmp/studio/entities.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Entity Store models owned by the Studio service.""" + +from typing import ClassVar, Literal + +from nmp.common.entities.client import EntityBase +from nmp.studio.copilot_artifacts import ChatArtifactsResponse +from pydantic import BaseModel, Field + + +class CopilotMessage(BaseModel): + """One user or assistant message in a persisted Copilot conversation.""" + + role: Literal["user", "assistant"] + content: str + + +class CopilotConversation(EntityBase): + """A workspace-scoped, user-owned NeMo Copilot conversation.""" + + __entity_type__: ClassVar[str] = "copilot_conversation" + + session_id: str = Field(description="Stable Studio session UUID exposed to the UI.") + owner_id: str = Field(description="Principal that owns and may read this conversation.") + messages: list[CopilotMessage] = Field(default_factory=list) + chat_artifacts: ChatArtifactsResponse = Field(default_factory=ChatArtifactsResponse) diff --git a/services/studio/src/nmp/studio/service.py b/services/studio/src/nmp/studio/service.py index 6fb610ef0b..b5054fb68c 100644 --- a/services/studio/src/nmp/studio/service.py +++ b/services/studio/src/nmp/studio/service.py @@ -86,7 +86,7 @@ class StudioService(Service[StudioConfig]): - env_replacements: Runtime values to inject into the UI bundle (cached) """ - dependencies: ClassVar[list[str]] = [] + dependencies: ClassVar[list[str]] = ["entities", "auth"] def __init__(self): """Initialize the studio service.""" diff --git a/services/studio/tests/unit/test_copilot.py b/services/studio/tests/unit/test_copilot.py index 247ba7642e..5ddf03ece6 100644 --- a/services/studio/tests/unit/test_copilot.py +++ b/services/studio/tests/unit/test_copilot.py @@ -9,7 +9,9 @@ import re import uuid from collections.abc import AsyncIterator +from datetime import UTC, datetime from pathlib import Path +from types import SimpleNamespace from typing import Any, cast import httpx @@ -17,11 +19,78 @@ from fastapi import FastAPI, HTTPException, Request from fastapi.responses import StreamingResponse from fastapi.testclient import TestClient +from nmp.common.entities.client import EntityConflictError, EntityNotFoundError +from nmp.common.service.dependencies import get_entity_client from nmp.studio import copilot, copilot_artifacts, copilot_skills, studio_links from nmp.studio.config import StudioConfig +from nmp.studio.entities import CopilotConversation, CopilotMessage from nmp.studio.service import StudioService +class FakeEntityStore: + """Small async EntityClient fake for Copilot route tests.""" + + def __init__(self) -> None: + self.entities: dict[tuple[str, str], CopilotConversation] = {} + + async def create(self, entity: CopilotConversation) -> CopilotConversation: + now = datetime.now(UTC) + entity._created_at = now + entity._updated_at = now + self.entities[(entity.workspace, entity.name)] = entity + return entity + + async def get( + self, + entity_type: type[CopilotConversation], + name: str, + *, + workspace: str | None = None, + ) -> CopilotConversation: + del entity_type + try: + return self.entities[(workspace or "default", name)] + except KeyError as exc: + raise EntityNotFoundError(name) from exc + + async def list( + self, + entity_type: type[CopilotConversation], + *, + workspace: str = "default", + filter_obj: dict[str, Any] | None = None, + **_: Any, + ) -> SimpleNamespace: + del entity_type + owner_id = filter_obj.get("owner_id") if filter_obj else None + data = [ + entity + for (entity_workspace, _), entity in self.entities.items() + if entity_workspace == workspace and (owner_id is None or entity.owner_id == owner_id) + ] + data.sort(key=lambda entity: entity.updated_at or datetime.min.replace(tzinfo=UTC), reverse=True) + return SimpleNamespace(data=data) + + async def update(self, entity: CopilotConversation) -> CopilotConversation: + entity._updated_at = datetime.now(UTC) + self.entities[(entity.workspace, entity.name)] = entity + return entity + + async def delete( + self, + entity_type: type[CopilotConversation], + name: str, + *, + workspace: str | None = None, + expected_db_version: int | None = None, + ) -> None: + del entity_type, expected_db_version + try: + del self.entities[(workspace or "default", name)] + except KeyError as exc: + raise EntityNotFoundError(name) from exc + + @pytest.fixture(autouse=True) def reset_copilot_state(): """Reset module-level bridge state between tests.""" @@ -29,22 +98,24 @@ def reset_copilot_state(): copilot._session_streams.clear() copilot._pending_permissions.clear() copilot._pending_agent_inputs.clear() - copilot._session_conversations.clear() - copilot._session_mtimes.clear() copilot._session_workspace_cache.clear() yield copilot._initialized_sessions.clear() copilot._session_streams.clear() copilot._pending_permissions.clear() copilot._pending_agent_inputs.clear() - copilot._session_conversations.clear() - copilot._session_mtimes.clear() copilot._session_workspace_cache.clear() @pytest.fixture -def service_client() -> TestClient: +def entity_store() -> FakeEntityStore: + return FakeEntityStore() + + +@pytest.fixture +def service_client(entity_store: FakeEntityStore) -> TestClient: service = StudioService() + service.app.dependency_overrides[get_entity_client] = lambda: entity_store return TestClient(service.app) @@ -236,43 +307,58 @@ def test_create_session_returns_uuid(service_client: TestClient): uuid.UUID(response.json()["session_id"]) -def test_create_session_evicts_least_recently_updated_session( +def test_create_session_persists_workspace_and_owner( service_client: TestClient, - monkeypatch: pytest.MonkeyPatch, + entity_store: FakeEntityStore, ): - monkeypatch.setattr(copilot, "MAX_RETAINED_SESSIONS", 2) - first_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] - copilot._session_mtimes[first_session_id] = 1 - second_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] - copilot._session_mtimes[second_session_id] = 2 - - third_session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + response = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) - assert set(copilot._session_conversations) == {second_session_id, third_session_id} - assert set(copilot._session_mtimes) == {second_session_id, third_session_id} + session_id = response.json()["session_id"] + persisted = entity_store.entities[("team-a", f"copilot-{session_id}")] + assert persisted.owner_id == "alice@example.com" + assert persisted.messages == [] -def test_retain_recent_turns_caps_complete_user_assistant_pairs(monkeypatch: pytest.MonkeyPatch): +def test_recent_conversation_messages_caps_model_context_without_mutating_history( + monkeypatch: pytest.MonkeyPatch, +): monkeypatch.setattr(copilot, "MAX_RETAINED_TURNS_PER_SESSION", 2) - conversation = [{"role": role, "content": f"{role}-{turn}"} for turn in range(3) for role in ("user", "assistant")] + conversation = [ + CopilotMessage(role=role, content=f"{role}-{turn}") for turn in range(3) for role in ("user", "assistant") + ] - copilot._retain_recent_turns(conversation) + recent = copilot._recent_conversation_messages(conversation) - assert conversation == [ + assert [message.model_dump() for message in recent] == [ {"role": "user", "content": "user-1"}, {"role": "assistant", "content": "assistant-1"}, {"role": "user", "content": "user-2"}, {"role": "assistant", "content": "assistant-2"}, ] + assert len(conversation) == 6 -def test_list_history_sessions_includes_retained_conversation(service_client: TestClient): +def test_list_history_sessions_includes_persisted_conversation( + service_client: TestClient, + entity_store: FakeEntityStore, +): session_id = str(uuid.uuid4()) - copilot._session_conversations[session_id] = [ - {"role": "user", "content": "Help me build an agent"}, - {"role": "assistant", "content": "What should it do?"}, - ] - copilot._session_mtimes[session_id] = 42 + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + messages=[ + CopilotMessage(role="user", content="Help me build an agent"), + CopilotMessage(role="assistant", content="What should it do?"), + ], + ) + conversation._created_at = datetime.fromtimestamp(40, UTC) + conversation._updated_at = datetime.fromtimestamp(42, UTC) + entity_store.entities[("default", conversation.name)] = conversation response = service_client.get("/v2/copilot/history/sessions") @@ -303,6 +389,132 @@ def test_list_history_sessions_includes_retained_conversation(service_client: Te ] +def test_history_is_scoped_to_workspace_and_owner( + service_client: TestClient, + entity_store: FakeEntityStore, +): + alice_id = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ).json()["session_id"] + bob_id = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "bob@example.com"}, + ).json()["session_id"] + entity_store.entities[("team-a", f"copilot-{alice_id}")].messages = [ + CopilotMessage(role="user", content="Alice's private prompt"), + CopilotMessage(role="assistant", content="Alice's answer"), + ] + entity_store.entities[("team-a", f"copilot-{bob_id}")].messages = [ + CopilotMessage(role="user", content="Bob's private prompt"), + CopilotMessage(role="assistant", content="Bob's answer"), + ] + + response = service_client.get( + "/v2/copilot/history/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) + + assert response.status_code == 200 + assert [session["session_id"] for session in response.json()] == [alice_id] + forbidden = service_client.get( + f"/v2/copilot/history/sessions/{bob_id}?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) + assert forbidden.status_code == 404 + + +def test_delete_history_enforces_owner_and_removes_conversation( + service_client: TestClient, + entity_store: FakeEntityStore, +): + session_id = service_client.post( + "/v2/copilot/sessions?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ).json()["session_id"] + + forbidden = service_client.delete( + f"/v2/copilot/history/sessions/{session_id}?workspace=team-a", + headers={"X-NMP-Principal-Id": "bob@example.com"}, + ) + assert forbidden.status_code == 404 + assert ("team-a", f"copilot-{session_id}") in entity_store.entities + + deleted = service_client.delete( + f"/v2/copilot/history/sessions/{session_id}?workspace=team-a", + headers={"X-NMP-Principal-Id": "alice@example.com"}, + ) + assert deleted.status_code == 204 + assert ("team-a", f"copilot-{session_id}") not in entity_store.entities + + +def test_delete_history_rejects_active_session( + service_client: TestClient, + entity_store: FakeEntityStore, +): + session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + copilot._session_streams[session_id] = asyncio.Queue() + + response = service_client.delete(f"/v2/copilot/history/sessions/{session_id}") + + assert response.status_code == 409 + assert ("default", f"copilot-{session_id}") in entity_store.entities + + +def test_copilot_turn_is_persisted_and_reused_as_context( + service_client: TestClient, + entity_store: FakeEntityStore, + monkeypatch: pytest.MonkeyPatch, +): + session_id = service_client.post("/v2/copilot/sessions").json()["session_id"] + invocations: list[list[dict[str, str]]] = [] + + async def fake_invoke( + agent_url: str, + headers: dict[str, str], + messages: list[dict[str, str]], + studio_session_id: str, + ) -> tuple[str, str]: + del agent_url, headers + assert studio_session_id == session_id + invocations.append(messages) + return f"answer-{len(invocations)}", "nvidia/copilot-model" + + monkeypatch.setattr(copilot, "_invoke_copilot", fake_invoke) + + first = service_client.post( + f"/v2/copilot/sessions/{session_id}/messages", + json={"message": "first question", "workspace": "default"}, + ) + second = service_client.post( + f"/v2/copilot/sessions/{session_id}/messages", + json={"message": "second question", "workspace": "default"}, + ) + + assert first.status_code == 200 + assert second.status_code == 200 + assert "event: done" in first.text + assert "event: done" in second.text + persisted = entity_store.entities[("default", f"copilot-{session_id}")] + assert [message.model_dump() for message in persisted.messages] == [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "answer-1"}, + {"role": "user", "content": "second question"}, + {"role": "assistant", "content": "answer-2"}, + ] + assert invocations[1][:2] == [ + {"role": "user", "content": "first question"}, + {"role": "assistant", "content": "answer-1"}, + ] + history = service_client.get(f"/v2/copilot/history/sessions/{session_id}") + assert [item["kind"] for item in history.json()["items"]] == [ + "user", + "assistant", + "user", + "assistant", + ] + + def test_build_claude_argv_uses_new_session_then_resume_flag(): session_id = str(uuid.uuid4()) @@ -1690,8 +1902,17 @@ def test_platform_route_stream_uses_deployed_copilot(monkeypatch: pytest.MonkeyP app = FastAPI() app.include_router(service.app.router, prefix="/apis/studio") service.configure_app(app) - client = TestClient(app) session_id = str(uuid.uuid4()) + entity_store = FakeEntityStore() + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + entity_store.entities[("default", conversation.name)] = conversation + app.dependency_overrides[get_entity_client] = lambda: entity_store + client = TestClient(app) captured: dict[str, Any] = {} async def fake_stream( @@ -1700,7 +1921,10 @@ async def fake_stream( agent_url: str, headers: dict[str, str], studio_system_prompt: str, + conversation: CopilotConversation, + entity_store: FakeEntityStore, ): + del conversation, entity_store captured.update( { "session_id": session_id, @@ -1782,8 +2006,17 @@ def test_platform_route_stream_infers_studio_url_from_browser_headers(monkeypatc app = FastAPI() app.include_router(service.app.router, prefix="/apis/studio") service.configure_app(app) - client = TestClient(app) session_id = str(uuid.uuid4()) + entity_store = FakeEntityStore() + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + entity_store.entities[("default", conversation.name)] = conversation + app.dependency_overrides[get_entity_client] = lambda: entity_store + client = TestClient(app) captured: dict[str, Any] = {} async def fake_stream( @@ -1792,7 +2025,10 @@ async def fake_stream( agent_url: str, headers: dict[str, str], studio_system_prompt: str, + conversation: CopilotConversation, + entity_store: FakeEntityStore, ): + del conversation, entity_store captured.update( { "session_id": session_id, @@ -2058,6 +2294,14 @@ def test_tool_use_stream_event_strips_internal_session_id(): @pytest.mark.asyncio async def test_stream_copilot_flushes_tool_events_before_final_response(monkeypatch: pytest.MonkeyPatch): session_id = str(uuid.uuid4()) + entity_store = FakeEntityStore() + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + await entity_store.create(conversation) async def fake_invoke(agent_url, headers, messages, studio_session_id): queue = copilot._session_streams[studio_session_id] @@ -2070,7 +2314,16 @@ async def fake_invoke(agent_url, headers, messages, studio_session_id): monkeypatch.setattr(copilot, "_invoke_copilot", fake_invoke) frames = [ - frame async for frame in copilot._stream_copilot(session_id, "hello", "https://agent.test/x", {}, "sys prompt") + frame + async for frame in copilot._stream_copilot( + session_id, + "hello", + "https://agent.test/x", + {}, + "sys prompt", + conversation, + entity_store, + ) ] body = "".join(frames) @@ -2081,6 +2334,71 @@ async def fake_invoke(agent_url, headers, messages, studio_session_id): # Both tool-use events survive and are emitted before the final assistant message. assert first_tool < final assert second_tool < final + assert [message.content for message in conversation.messages] == ["hello", "final answer"] + + +@pytest.mark.asyncio +async def test_stream_copilot_retries_conflicted_conversation_update(monkeypatch: pytest.MonkeyPatch): + session_id = str(uuid.uuid4()) + conversation = CopilotConversation( + name=f"copilot-{session_id}", + workspace="default", + session_id=session_id, + owner_id="local-user", + ) + concurrent_conversation = conversation.model_copy(deep=True) + concurrent_conversation.messages.extend( + [ + CopilotMessage(role="user", content="remote question"), + CopilotMessage(role="assistant", content="remote answer"), + ] + ) + + class ConflictingEntityStore(FakeEntityStore): + def __init__(self) -> None: + super().__init__() + self.update_calls = 0 + + async def update(self, entity: CopilotConversation) -> CopilotConversation: + self.update_calls += 1 + if self.update_calls == 1: + self.entities[(concurrent_conversation.workspace, concurrent_conversation.name)] = ( + concurrent_conversation + ) + raise EntityConflictError("conversation was updated by another replica") + return await super().update(entity) + + entity_store = ConflictingEntityStore() + await entity_store.create(conversation) + + async def fake_invoke(agent_url, headers, messages, studio_session_id): + return "local answer", "model-x" + + monkeypatch.setattr(copilot, "_invoke_copilot", fake_invoke) + + frames = [ + frame + async for frame in copilot._stream_copilot( + session_id, + "local question", + "https://agent.test/x", + {}, + "sys prompt", + conversation, + entity_store, + ) + ] + + assert "event: done" in "".join(frames) + assert entity_store.update_calls == 2 + persisted = entity_store.entities[("default", conversation.name)] + assert [message.content for message in persisted.messages] == [ + "remote question", + "remote answer", + "local question", + "local answer", + ] + assert persisted.chat_artifacts.copilot_model == "model-x" def test_copilot_request_payload_keeps_session_outside_model_messages(): diff --git a/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx b/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx index da5147090e..09a9c8372a 100644 --- a/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx +++ b/web/packages/studio/src/components/Layouts/GlobalNav/index.test.tsx @@ -20,8 +20,8 @@ vi.mock('@studio/routes/PageLayout/ThemeSwitch', () => ({ ThemeSwitch: () =>
, })); -vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat', () => ({ - ClaudeCodeTopBarChat: () =>
, +vi.mock('@studio/routes/agents/CopilotChatRoute/CopilotTopBarChat', () => ({ + CopilotTopBarChat: () =>
, })); vi.mock('@studio/constants/environment', async (importOriginal) => { diff --git a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx index 3314e65811..3550621901 100644 --- a/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx +++ b/web/packages/studio/src/components/Layouts/GlobalNav/index.tsx @@ -7,7 +7,7 @@ import { UserPopover } from '@studio/components/UserPopover'; import { TOUR_ENABLED } from '@studio/constants/environment'; import { ROUTES } from '@studio/constants/routes'; import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath'; -import { ClaudeCodeTopBarChat } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeTopBarChat'; +import { CopilotTopBarChat } from '@studio/routes/agents/CopilotChatRoute/CopilotTopBarChat'; import { ThemeSwitch } from '@studio/routes/PageLayout/ThemeSwitch'; import { getWorkspaceDetailsDefaultRoute } from '@studio/routes/utils'; import { useSidebarState } from '@studio/util/hooks/useSidebarState'; @@ -25,18 +25,18 @@ interface Props { interface GlobalNavContentProps extends Props { isDashboardRoute: boolean; - isClaudeCodeChatRoute: boolean; + isCopilotChatRoute: boolean; } const GlobalNavContent: FC = ({ sideNav, isDashboardRoute, - isClaudeCodeChatRoute, + isCopilotChatRoute, }) => { const workspace = useWorkspaceFromPathIfExists(); - const { expanded, toggle } = useSidebarState(!isClaudeCodeChatRoute); - const shouldMountClaudeCodeTopBarChat = !isDashboardRoute && !isClaudeCodeChatRoute; - const sidebarBackground = isClaudeCodeChatRoute + const { expanded, toggle } = useSidebarState(!isCopilotChatRoute); + const shouldMountCopilotTopBarChat = !isDashboardRoute && !isCopilotChatRoute; + const sidebarBackground = isCopilotChatRoute ? 'bg-surface-sunken dark:bg-surface-base' : 'bg-surface-navigation'; @@ -86,7 +86,7 @@ const GlobalNavContent: FC = ({ )} - {shouldMountClaudeCodeTopBarChat && } + {shouldMountCopilotTopBarChat && } @@ -96,7 +96,7 @@ const GlobalNavContent: FC = ({ /> {sideNav && (
{sideNav(!expanded)} @@ -110,15 +110,15 @@ export const GlobalNav: FC = ({ sideNav }) => { const location = useLocation(); const isDashboardRoute = matchPath({ path: ROUTES.workspace.dashboard, end: true }, location.pathname) !== null; - const isClaudeCodeChatRoute = + const isCopilotChatRoute = matchPath({ path: ROUTES.workspace.copilotChat, end: true }, location.pathname) !== null; return ( ); }; diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx index 8a233494c8..42d91bb156 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.test.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { ROUTES } from '@studio/constants/routes'; -import { getClaudeCodeActiveSessionStorageKey } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; +import { getCopilotActiveSessionStorageKey } from '@studio/routes/agents/CopilotChatRoute/activeSessionStorage'; import { DashboardLandingRoute } from '@studio/routes/DashboardLandingRoute'; import { mockFeatureFlags } from '@studio/tests/util/mockFeatureFlags'; import { TestProviders } from '@studio/tests/util/TestProviders'; @@ -10,13 +10,13 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { createMemoryRouter, generatePath, RouterProvider, useLocation } from 'react-router'; -vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', async (importOriginal) => { +vi.mock('@studio/routes/agents/CopilotChatRoute/api', async (importOriginal) => { const actual = - await importOriginal(); + await importOriginal(); return { ...actual, - listClaudeCodeHistorySessions: vi.fn(async () => []), + listCopilotHistorySessions: vi.fn(async () => []), }; }); @@ -121,7 +121,7 @@ describe('DashboardLandingRoute', () => { it('clears the active NeMo Copilot session before starting from the landing composer', async () => { const user = userEvent.setup(); - localStorage.setItem(getClaudeCodeActiveSessionStorageKey(workspace), 'session-existing'); + localStorage.setItem(getCopilotActiveSessionStorageKey(workspace), 'session-existing'); renderRoute(); await user.type( @@ -130,7 +130,7 @@ describe('DashboardLandingRoute', () => { ); await user.click(screen.getByRole('button', { name: 'Send message' })); - expect(localStorage.getItem(getClaudeCodeActiveSessionStorageKey(workspace))).toBeNull(); + expect(localStorage.getItem(getCopilotActiveSessionStorageKey(workspace))).toBeNull(); }); it('submits the landing composer when Enter is pressed', async () => { diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx index 9110f84f0b..3496e00055 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx +++ b/web/packages/studio/src/routes/DashboardLandingRoute/index.tsx @@ -6,9 +6,9 @@ import { Button, Flex, Text, TextArea, Tooltip } from '@nvidia/foundations-react import { AccessibleTitle } from '@studio/components/AccessibleTitle'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; import { useBreadcrumbs } from '@studio/providers/breadcrumbs/useBreadcrumbs'; -import { writeStoredActiveSessionId } from '@studio/routes/agents/ClaudeCodeChatRoute/activeSessionStorage'; -import { ClaudeCodeLayout } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeLayout'; -import type { ClaudeCodeChatRouteState } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { writeStoredActiveSessionId } from '@studio/routes/agents/CopilotChatRoute/activeSessionStorage'; +import { CopilotLayout } from '@studio/routes/agents/CopilotChatRoute/CopilotLayout'; +import type { CopilotChatRouteState } from '@studio/routes/agents/CopilotChatRoute/types'; import { getCopilotChatRoute } from '@studio/routes/utils'; import { Send, Terminal } from 'lucide-react'; import { @@ -105,14 +105,14 @@ export const DashboardLandingRoute: FC = () => { const handleSubmit = useCallback( (prompt: string) => { writeStoredActiveSessionId(workspace, null); - const state: ClaudeCodeChatRouteState = { initialPrompt: prompt }; + const state: CopilotChatRouteState = { initialPrompt: prompt }; navigate(getCopilotChatRoute(workspace), { state }); }, [navigate, workspace] ); return ( - +
@@ -128,6 +128,6 @@ export const DashboardLandingRoute: FC = () => {
-
+ ); }; diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts index 2b2df3489d..90d2ff56d3 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionSuggestions.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { featureFlags } from '@studio/constants/featureFlags'; -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; import { SKILL_ACTION_TEMPLATES, type SkillActionSuggestion, @@ -19,7 +19,7 @@ export type { const isSkillActionTemplateName = (skillName: string): skillName is SkillActionTemplateName => Object.prototype.hasOwnProperty.call(SKILL_ACTION_TEMPLATES, skillName); -const getSkillActionTemplate = (skill: ClaudeCodeSkill): SkillActionTemplate | undefined => { +const getSkillActionTemplate = (skill: CopilotSkill): SkillActionTemplate | undefined => { for (const lookupKey of getSkillLookupKeys(skill)) { if (isSkillActionTemplateName(lookupKey)) { return SKILL_ACTION_TEMPLATES[lookupKey]; @@ -32,7 +32,7 @@ const getSkillActionTemplate = (skill: ClaudeCodeSkill): SkillActionTemplate | u export const isSkillActionEnabled = (template: SkillActionTemplate) => template.requiredFeatureFlags?.every((flag) => featureFlags[flag] !== false) ?? true; -export const getSkillActionSuggestions = (skills: ClaudeCodeSkill[]): SkillActionSuggestion[] => { +export const getSkillActionSuggestions = (skills: CopilotSkill[]): SkillActionSuggestion[] => { const seenSkills = new Set(); const suggestions: SkillActionSuggestion[] = []; diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts index 0abf746f41..64aff9267d 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillActionTemplates.test.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; import { getSkillActionSuggestions } from '@studio/routes/DashboardLandingRoute/skillActionSuggestions'; import { mockFeatureFlags } from '@studio/tests/util/mockFeatureFlags'; -const skill = (overrides: Partial): ClaudeCodeSkill => ({ +const skill = (overrides: Partial): CopilotSkill => ({ name: 'inference', claude_name: 'nemo-inference', description: 'Use NeMo Platform inference.', diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts index bf02d2eb48..e0513dcf09 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.test.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; import { getSkillDisplayName, getSkillLookupKeys, } from '@studio/routes/DashboardLandingRoute/skillDisplayName'; -const skill = (overrides: Partial): ClaudeCodeSkill => ({ +const skill = (overrides: Partial): CopilotSkill => ({ name: 'inference', claude_name: 'nemo-inference', description: 'Use NeMo Platform inference.', diff --git a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts index 5588f9ce94..a9698eae13 100644 --- a/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts +++ b/web/packages/studio/src/routes/DashboardLandingRoute/skillDisplayName.ts @@ -1,13 +1,13 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ClaudeCodeSkill } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import type { CopilotSkill } from '@studio/routes/agents/CopilotChatRoute/types'; const titleCaseSkillSegment = (segment: string): string => segment ? segment.charAt(0).toUpperCase() + segment.slice(1) : segment; /** Strip repeated ``nemo-`` prefixes before title-casing skill folder names. */ -export const getSkillLookupKeys = (skill: ClaudeCodeSkill): string[] => { +export const getSkillLookupKeys = (skill: CopilotSkill): string[] => { const keys = new Set(); for (const rawName of [skill.name, skill.claude_name]) { @@ -22,7 +22,7 @@ export const getSkillLookupKeys = (skill: ClaudeCodeSkill): string[] => { return [...keys]; }; -export const getSkillDisplayName = (skill: ClaudeCodeSkill): string => { +export const getSkillDisplayName = (skill: CopilotSkill): string => { let name = skill.name; while (name.startsWith('nemo-')) { name = name.slice(5); diff --git a/web/packages/studio/src/routes/PageLayout/index.tsx b/web/packages/studio/src/routes/PageLayout/index.tsx index 55e80656e4..507bb197dd 100644 --- a/web/packages/studio/src/routes/PageLayout/index.tsx +++ b/web/packages/studio/src/routes/PageLayout/index.tsx @@ -7,7 +7,7 @@ import { useWorkspaceFromPathIfExists } from '@studio/hooks/useWorkspaceFromPath import { useAuthAutoLogin } from '@studio/providers/auth'; import { useAuthTokenStatus } from '@studio/providers/auth/useAuthTokenStatus'; import { useSelectedWorkspace } from '@studio/providers/workspace'; -import { ClaudeCodeChatProvider } from '@studio/routes/agents/ClaudeCodeChatRoute/context/ClaudeCodeChatProvider'; +import { CopilotChatProvider } from '@studio/routes/agents/CopilotChatRoute/context/CopilotChatProvider'; import { WorkspaceGuard } from '@studio/routes/RootLayout/WorkspaceGuard'; import { ReactNode } from 'react'; import { Outlet } from 'react-router'; @@ -48,9 +48,9 @@ export const PageLayout = ({ sideNav }: { sideNav?: (collapsed: boolean) => Reac className={`min-h-screen relative grid size-full text-primary grid-cols-[auto_minmax(0,1fr)] grid-rows-[auto_1fr] ${gridAreas}`} > {COPILOT_STUDIO_ENABLED && workspace ? ( - + {layout} - + ) : ( layout )} diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext.ts deleted file mode 100644 index aca966cd53..0000000000 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/context/useClaudeCodeChatContext.ts +++ /dev/null @@ -1,28 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { ClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; -import { createContext, useContext } from 'react'; - -export type ClaudeCodeChatLoadStatus = 'idle' | 'loading' | 'error'; - -export interface ClaudeCodeChatContextValue { - /** The single chat runtime shared by the full chat route and the pop-out. */ - chat: ClaudeCodeChatRuntime; - /** Status of the most recent `loadSession` fetch. */ - loadStatus: ClaudeCodeChatLoadStatus; - /** Fetch a session's history and load it into the shared runtime. */ - loadSession: (sessionId: string) => void; - /** Reset the shared runtime to a fresh, empty chat. */ - startNewChat: () => void; -} - -export const ClaudeCodeChatContext = createContext(null); - -export const useClaudeCodeChatContext = (): ClaudeCodeChatContextValue => { - const context = useContext(ClaudeCodeChatContext); - if (!context) { - throw new Error('useClaudeCodeChatContext must be used within a ClaudeCodeChatProvider'); - } - return context; -}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx deleted file mode 100644 index 0c2b2ee15e..0000000000 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/historyPanel/HistorySessionButton.tsx +++ /dev/null @@ -1,69 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { Flex, Stack, Text } from '@nvidia/foundations-react-core'; -import { ToolCallSummary } from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/ArtifactSections'; -import { - getCompactRelativeTime, - getHistorySessionTitle, -} from '@studio/routes/agents/ClaudeCodeChatRoute/historyPanel/helpers'; -import type { ClaudeCodeHistorySession } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; -import cn from 'classnames'; -import { MessageSquare } from 'lucide-react'; -import React from 'react'; - -interface HistorySessionButtonProps { - active: boolean; - onSelect: () => void; - session: ClaudeCodeHistorySession; -} - -export const HistorySessionButton = ({ - active, - onSelect, - session, -}: HistorySessionButtonProps): React.JSX.Element => { - const sessionTitle = getHistorySessionTitle(session); - const timestamp = new Date(session.mtime * 1000).toLocaleString(); - const prompt = session.first_prompt.trim(); - const tooltip = prompt ? `${timestamp}\n\n${prompt}` : timestamp; - - return ( - - ); -}; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts deleted file mode 100644 index 69b42e0ef7..0000000000 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -export interface ClaudeCodeStreamHandlers { - onClaudeEvent: (event: unknown) => void; - onInputRequest: (request: ClaudeCodeInputRequest) => void; - onPermissionRequest: (request: ClaudeCodePermissionRequest) => void; - onInputExpired?: (requestId: string) => void; - onPermissionExpired?: (requestId: string) => void; - onDone: () => void; - onError: (error: Error) => void; -} - -export interface ClaudeCodePermissionRequest { - requestId: string; - toolName: string; - input: Record; - toolUseId?: string; -} - -export interface ClaudeCodePermissionDecision { - approved: boolean; - reason?: string; - updatedInput?: Record; -} - -export type ClaudeCodeInputRequestKind = 'agent' | 'eval_config' | 'dataset_file' | 'model'; - -export interface ClaudeCodeInputRequest { - requestId: string; - kind: ClaudeCodeInputRequestKind; - input: Record; -} - -export interface ClaudeCodeInputDecision { - skipped?: boolean; - value?: Record; -} - -export interface ClaudeCodeChatRouteState { - initialPrompt?: string; -} - -export interface ClaudeCodeChatSelectionArtifact { - label: string; - value: string; -} - -export interface ClaudeCodeChatFileArtifact { - action: string; - path: string; -} - -export interface ClaudeCodeChatLinkArtifact { - label: string; - destination?: string; - href?: string; -} - -export interface ClaudeCodeChatJobArtifact { - name: string; - job_type?: string; - source?: string; - href?: string; -} - -export type ClaudeCodeChatModelSource = 'copilot' | 'selection' | 'spec'; - -export interface ClaudeCodeChatArtifacts { - agent?: string; - model?: string; - model_source?: ClaudeCodeChatModelSource; - copilot_model?: string; - workspace?: string; - selections: ClaudeCodeChatSelectionArtifact[]; - files: ClaudeCodeChatFileArtifact[]; - links: ClaudeCodeChatLinkArtifact[]; - jobs: ClaudeCodeChatJobArtifact[]; - tools: string[]; -} - -export interface ClaudeCodeHistorySession { - session_id: string; - mtime: number; - title?: string; - first_prompt: string; - message_count: number; - token_count: number; - tool_call_count: number; - tool_calls: string[]; - chat_artifacts: ClaudeCodeChatArtifacts; -} - -export interface ClaudeCodeSkill { - name: string; - claude_name: string; - description: string; - source: string; - source_path?: string | null; - install_path: string; - installed: boolean; -} - -export interface ClaudeCodeUserHistoryItem { - kind: 'user'; - text: string; -} - -export interface ClaudeCodeAssistantTextPart { - type: 'text'; - text: string; -} - -export interface ClaudeCodeAssistantToolUsePart { - type: 'tool_use'; - id?: string; - name: string; - input: Record; -} - -export type ClaudeCodeAssistantHistoryPart = - | ClaudeCodeAssistantTextPart - | ClaudeCodeAssistantToolUsePart; - -export interface ClaudeCodeAssistantHistoryItem { - kind: 'assistant'; - parts: ClaudeCodeAssistantHistoryPart[]; -} - -export type ClaudeCodeSessionHistoryItem = - | ClaudeCodeUserHistoryItem - | ClaudeCodeAssistantHistoryItem; - -export interface ClaudeCodeSessionHistory { - session_id: string; - items: ClaudeCodeSessionHistoryItem[]; - chat_artifacts: ClaudeCodeChatArtifacts; -} diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.test.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.test.tsx similarity index 88% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.test.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.test.tsx index 4d26533e5d..afec96235f 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.test.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.test.tsx @@ -1,16 +1,16 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { getBlockingInputRequest } from '@studio/routes/agents/ClaudeCodeChatRoute/blockingInputRequest'; +import { getBlockingInputRequest } from '@studio/routes/agents/CopilotChatRoute/blockingInputRequest'; import type { - ClaudeCodeInputRequest, - ClaudeCodeInputRequestKind, -} from '@studio/routes/agents/ClaudeCodeChatRoute/types'; + CopilotInputRequest, + CopilotInputRequestKind, +} from '@studio/routes/agents/CopilotChatRoute/types'; const makeRequest = ( - kind: ClaudeCodeInputRequestKind, + kind: CopilotInputRequestKind, input: Record = {} -): ClaudeCodeInputRequest => ({ +): CopilotInputRequest => ({ requestId: 'request-1', kind, input, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.tsx similarity index 92% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.tsx index a80b716be2..8bdc730cd8 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/BlockingInputComposer.tsx @@ -9,12 +9,12 @@ import { type AgentBlockingInputStatus, type AgentBlockingInputSubmission, } from '@studio/components/agents/AgentBlockingInput'; -import { getBlockingInputRequest } from '@studio/routes/agents/ClaudeCodeChatRoute/blockingInputRequest'; -import type { ClaudeCodeInputRequest } from '@studio/routes/agents/ClaudeCodeChatRoute/types'; +import { getBlockingInputRequest } from '@studio/routes/agents/CopilotChatRoute/blockingInputRequest'; +import type { CopilotInputRequest } from '@studio/routes/agents/CopilotChatRoute/types'; import { type FC } from 'react'; interface BlockingInputComposerProps { - readonly inputRequest: ClaudeCodeInputRequest; + readonly inputRequest: CopilotInputRequest; readonly inputStatus: AgentBlockingInputStatus; readonly workspace: string; readonly onSubmit: (submission: AgentBlockingInputSubmission) => Promise | void; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.test.tsx similarity index 91% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.test.tsx index 7dbd25ba52..b077995533 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.test.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.test.tsx @@ -2,11 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { ROUTES } from '@studio/constants/routes'; -import { ClaudeCodeChatThread } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread'; +import { CopilotChatThread } from '@studio/routes/agents/CopilotChatRoute/CopilotChatThread'; import type { - ClaudeCodeChatRuntime, + CopilotChatRuntime, StudioNavigationRequest, -} from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; +} from '@studio/routes/agents/CopilotChatRoute/useCopilotChatRuntime'; import { TestProviders } from '@studio/tests/util/TestProviders'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -79,14 +79,14 @@ const makeChat = (studioNavigationRequest: StudioNavigationRequest | null) => studioNavigationRequest, studioNavigationStatus: 'pending', submitPrompt: vi.fn(), - }) as unknown as ClaudeCodeChatRuntime; + }) as unknown as CopilotChatRuntime; const renderThread = (studioNavigationRequest = makeStudioNavigationRequest()) => { const router = createMemoryRouter( [ { path: ROUTES.workspace.copilotChat, - element: , + element: , }, { path: ROUTES.workspace.guardrails, element:
}, ], @@ -100,7 +100,7 @@ const renderThread = (studioNavigationRequest = makeStudioNavigationRequest()) = ); }; -describe('ClaudeCodeChatThread Studio UI navigation', () => { +describe('CopilotChatThread Studio UI navigation', () => { beforeEach(() => { vi.clearAllMocks(); }); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.tsx similarity index 90% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.tsx index 2a4a269b5b..ac5fcd100f 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotChatThread.tsx @@ -9,14 +9,14 @@ import { type AgentDecisionChoice, } from '@studio/components/agents/AgentDecisionInput'; import { useWorkspaceFromPath } from '@studio/hooks/useWorkspaceFromPath'; -import { BlockingInputComposer } from '@studio/routes/agents/ClaudeCodeChatRoute/BlockingInputComposer'; -import { ClaudeCodeStudioLink } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeStudioLink'; -import { ClaudeCodeToolCallPart } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeToolCallPart'; -import type { ClaudeCodeChatRuntime } from '@studio/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime'; +import { BlockingInputComposer } from '@studio/routes/agents/CopilotChatRoute/BlockingInputComposer'; +import { CopilotStudioLink } from '@studio/routes/agents/CopilotChatRoute/CopilotStudioLink'; +import { CopilotToolCallPart } from '@studio/routes/agents/CopilotChatRoute/CopilotToolCallPart'; +import type { CopilotChatRuntime } from '@studio/routes/agents/CopilotChatRoute/useCopilotChatRuntime'; import { type FC, useCallback, useLayoutEffect, useMemo, useRef } from 'react'; import { useNavigate } from 'react-router'; -const MESSAGE_CONTENT_PROPS = { markdownLinkComponent: ClaudeCodeStudioLink }; +const MESSAGE_CONTENT_PROPS = { markdownLinkComponent: CopilotStudioLink }; const EMPTY_STATE = { slotHeading: 'Start a NeMo Copilot session', @@ -34,14 +34,14 @@ const CHAT_VIEWPORT_SCROLLBAR_CLASS = [ '[&::-webkit-scrollbar-thumb:hover]:bg-[var(--border-color-interaction-strong)]', ].join(' '); -interface ClaudeCodeChatThreadProps { - chat: ClaudeCodeChatRuntime; +interface CopilotChatThreadProps { + chat: CopilotChatRuntime; mode?: 'full' | 'compact'; onReset?: () => void; scrollToBottomSignal?: number; } -export const ClaudeCodeChatThread: FC = ({ +export const CopilotChatThread: FC = ({ chat, mode = 'full', onReset, @@ -157,7 +157,7 @@ export const ClaudeCodeChatThread: FC = ({ } viewportClassName={CHAT_VIEWPORT_SCROLLBAR_CLASS} hideAssistantMessageActions - toolCallPartComponent={ClaudeCodeToolCallPart} + toolCallPartComponent={CopilotToolCallPart} attributes={{ ThreadViewport: { ref: chatViewportRef, diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotHistoryPanel.test.tsx similarity index 74% rename from web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx rename to web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotHistoryPanel.test.tsx index e7a795fb17..286df59d7c 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel.test.tsx +++ b/web/packages/studio/src/routes/agents/CopilotChatRoute/CopilotHistoryPanel.test.tsx @@ -1,28 +1,37 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { ClaudeCodeHistoryPanel } from '@studio/routes/agents/ClaudeCodeChatRoute/ClaudeCodeHistoryPanel'; +import { CopilotHistoryPanel } from '@studio/routes/agents/CopilotChatRoute/CopilotHistoryPanel'; import { render, screen } from '@studio/tests/util/render'; +import { waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; const mocks = vi.hoisted(() => ({ - listClaudeCodeHistorySessions: vi.fn(), - listClaudeCodeSkills: vi.fn(), + deleteCopilotSessionHistory: vi.fn(), + listCopilotHistorySessions: vi.fn(), + listCopilotSkills: vi.fn(), })); -vi.mock('@studio/routes/agents/ClaudeCodeChatRoute/api', () => ({ - CLAUDE_CODE_HISTORY_SESSIONS_QUERY_KEY: ['claude-code', 'history', 'sessions'], - CLAUDE_CODE_SKILLS_QUERY_KEY: ['claude-code', 'skills'], - listClaudeCodeHistorySessions: mocks.listClaudeCodeHistorySessions, - listClaudeCodeSkills: mocks.listClaudeCodeSkills, +vi.mock('@studio/routes/agents/CopilotChatRoute/api', () => ({ + COPILOT_SKILLS_QUERY_KEY: ['copilot', 'skills'], + deleteCopilotSessionHistory: mocks.deleteCopilotSessionHistory, + getCopilotHistorySessionsQueryKey: (workspace: string) => [ + 'copilot', + 'history', + 'sessions', + workspace, + ], + listCopilotHistorySessions: mocks.listCopilotHistorySessions, + listCopilotSkills: mocks.listCopilotSkills, })); -describe('ClaudeCodeHistoryPanel', () => { +describe('CopilotHistoryPanel', () => { beforeEach(() => { localStorage.clear(); vi.clearAllMocks(); - mocks.listClaudeCodeHistorySessions.mockResolvedValue([]); - mocks.listClaudeCodeSkills.mockResolvedValue([ + mocks.listCopilotHistorySessions.mockResolvedValue([]); + mocks.deleteCopilotSessionHistory.mockResolvedValue(undefined); + mocks.listCopilotSkills.mockResolvedValue([ { name: 'inference', claude_name: 'nemo-inference', @@ -38,7 +47,7 @@ describe('ClaudeCodeHistoryPanel', () => { it('starts history and skills collapsed and expands them independently', async () => { const user = userEvent.setup(); render( - { const user = userEvent.setup(); const onNewChat = vi.fn(); const onSelectSession = vi.fn(); - mocks.listClaudeCodeHistorySessions.mockResolvedValue([ + mocks.listCopilotHistorySessions.mockResolvedValue([ { session_id: 'session-1', mtime: Date.now() / 1000, @@ -99,7 +108,7 @@ describe('ClaudeCodeHistoryPanel', () => { ]); const { unmount } = render( - { await user.click(screen.getByRole('button', { name: 'New chat' })); expect(onNewChat).toHaveBeenCalledTimes(1); - await user.click(screen.getByRole('button', { name: /Review the latest agent work/ })); + await user.click( + screen.getByRole('button', { name: 'Open chat Review the latest agent work' }) + ); expect(onSelectSession).toHaveBeenCalledWith('session-1'); unmount(); render( - { it('shows the summarized title while preserving the full first prompt in the tooltip', async () => { const user = userEvent.setup(); const firstPrompt = 'I want to create an agent that does spam detection for incoming email.'; - mocks.listClaudeCodeHistorySessions.mockResolvedValue([ + mocks.listCopilotHistorySessions.mockResolvedValue([ { session_id: 'session-1', mtime: Date.now() / 1000, @@ -156,7 +167,7 @@ describe('ClaudeCodeHistoryPanel', () => { ]); render( - { await user.click(screen.getByRole('button', { name: 'Expand All Chats' })); const sessionButton = await screen.findByRole('button', { - name: 'Create Spam Detector Agent now', + name: 'Open chat Create Spam Detector Agent', }); expect(sessionButton).toHaveAttribute('title', expect.stringContaining(firstPrompt)); expect(screen.queryByText(firstPrompt)).not.toBeInTheDocument(); }); + it('confirms deletion and starts a new chat when deleting the active session', async () => { + const user = userEvent.setup(); + const onNewChat = vi.fn(); + mocks.listCopilotHistorySessions.mockResolvedValue([ + { + session_id: 'session-1', + mtime: Date.now() / 1000, + title: 'Private agent work', + first_prompt: 'Help me with private agent work', + message_count: 1, + token_count: 0, + tool_call_count: 0, + tool_calls: [], + chat_artifacts: { + selections: [], + files: [], + links: [], + jobs: [], + tools: [], + }, + }, + ]); + + render( + + ); + await user.click(screen.getByRole('button', { name: 'Expand All Chats' })); + await user.click(await screen.findByRole('button', { name: 'Delete chat Private agent work' })); + + expect(screen.getByRole('dialog', { name: 'Delete chat?' })).toBeInTheDocument(); + expect( + screen.getByText('Delete “Private agent work”? This chat cannot be recovered.') + ).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Delete' })); + + await waitFor(() => + expect(mocks.deleteCopilotSessionHistory).toHaveBeenCalledWith('session-1', 'team-a') + ); + expect(onNewChat).toHaveBeenCalledTimes(1); + }); + it('renders job artifacts as Studio links', () => { render( - { it('does not treat workspace metadata as a visible chat artifact', () => { render( - { it('omits empty artifact sections and their dividers', () => { render( - { it('ignores selections with whitespace-only values', () => { render( - { it('lists NeMo Copilot skills in the expanded skills block', async () => { const user = userEvent.setup(); render( - = ({ - hideArtifacts, - ...props -}) => { - const [historyOpen, setHistoryOpen] = useLocalStorage(CLAUDE_CODE_HISTORY_OPEN_KEY, 'true'); +export const CopilotHistoryPanel: FC = ({ hideArtifacts, ...props }) => { + const [historyOpen, setHistoryOpen] = useLocalStorage(COPILOT_HISTORY_OPEN_KEY, 'true'); const [openFloatingPanel, setOpenFloatingPanel, clearOpenFloatingPanel] = - useLocalStorage(CLAUDE_CODE_OPEN_FLOATING_PANEL_KEY); + useLocalStorage(COPILOT_OPEN_FLOATING_PANEL_KEY); const isOpen = historyOpen !== 'false'; const toggleLabel = isOpen ? 'Collapse NeMo Copilot history' : 'Expand NeMo Copilot history'; const handleFloatingPanelOpenChange = (panel: OpenFloatingPanel, open: boolean) => { @@ -55,7 +52,7 @@ export const ClaudeCodeHistoryPanel: FC = ({ return (