From cdb783829b892b79b31ac74085aabf250285aaeb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 03:58:19 +0000 Subject: [PATCH] The assistant can search the web and be asked as one of the suite's personas web_search is a looking tool like the others: the suite runs the search and credits it to the conversation, and what comes back is for the model to read and cite, never a write. A failed search is a result the model reads, not a failed turn. A turn may be asked as one of the suite's personas, chosen in a picker that appears only when the suite has some. The persona is kept on the turn row, so a turn a restart reruns is asked as the same one, and the suite applies its prompt and model; this app only names it. The personas endpoint returns only what a picker shows, never a system prompt, and is empty for any reason at all. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01V1geD2ELWgL2sDpxYbx3Aw --- CLAUDE.md | 5 + .../20261104_0900_add_assistant_persona.py | 30 ++++ backend/app/assistant/prompt.py | 2 + backend/app/assistant/tools.py | 32 ++++- backend/app/assistant/worker.py | 12 +- backend/app/models/assistant.py | 4 + backend/app/routers/assistant.py | 31 ++++- backend/app/suite.py | 22 ++- backend/tests/test_assistant.py | 130 ++++++++++++++++++ frontend/src/api/assistant.ts | 20 ++- .../src/components/AssistantPanel.test.tsx | 40 +++++- frontend/src/components/AssistantPanel.tsx | 29 +++- frontend/src/stores/assistant.test.ts | 48 ++++++- frontend/src/stores/assistant.ts | 32 ++++- 14 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 backend/alembic/versions/20261104_0900_add_assistant_persona.py diff --git a/CLAUDE.md b/CLAUDE.md index 1aac886..87e8f4d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,11 @@ Gecko Suite, through the two functions every job already calls, `build_provider` proposal reaches the library, and only when a person presses Apply. A new tool that writes would break that, so make it a proposal kind instead. Every tool re-checks that the asset is the caller's: the model chooses the ids. + `web_search` is one of the looking tools: the suite runs the search (its own backend, else + DuckDuckGo) and credits it to the conversation, and what comes back is for the model to + read and cite, never a write. A **persona** is the suite's, chosen per turn and kept on the + turn row so a rerun after a restart is asked as the same one; the suite applies its prompt + and model, and this app only names it. - **The embedding width is `SUITE_EMBEDDING_DIMENSIONS` (512) and must not change under a populated library:** vectors of another width cannot be compared with the existing index. diff --git a/backend/alembic/versions/20261104_0900_add_assistant_persona.py b/backend/alembic/versions/20261104_0900_add_assistant_persona.py new file mode 100644 index 0000000..9cd0838 --- /dev/null +++ b/backend/alembic/versions/20261104_0900_add_assistant_persona.py @@ -0,0 +1,30 @@ +"""add the persona an assistant turn was asked as + +A turn may be asked as one of the suite's personas. Kept on the turn so a rerun after a +restart is asked as the same one. Nullable: every existing turn was asked as none. + +Revision ID: e8c41a7b2d56 +Revises: c3a95e7d1f42 +Create Date: 2026-11-04 09:00:00.000000+00:00 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +revision: str = 'e8c41a7b2d56' +down_revision: Union[str, None] = 'c3a95e7d1f42' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + with op.batch_alter_table('assistant_turn', schema=None) as batch_op: + batch_op.add_column(sa.Column('persona_id', sqlmodel.sql.sqltypes.AutoString(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('assistant_turn', schema=None) as batch_op: + batch_op.drop_column('persona_id') diff --git a/backend/app/assistant/prompt.py b/backend/app/assistant/prompt.py index 1b2e7bf..b4958b9 100644 --- a/backend/app/assistant/prompt.py +++ b/backend/app/assistant/prompt.py @@ -15,6 +15,8 @@ - You cannot change the library. propose_changes and propose_enrichment record a proposal; the \ person reads it and applies it or throws it away. Say plainly what you are proposing and why. \ Never say a change "has been made". +- The web is for what the library cannot say. Use web_search sparingly, and say where a fact + came from. Never present something from the web as if it were in the asset itself. - Prefer the tags the person already uses (list_tags). Invent a new one only when none fits. - Change as little as you must. Leave a good name, description or summary alone. Propose \ changes to the assets the request is about, not to everything you happened to see. diff --git a/backend/app/assistant/tools.py b/backend/app/assistant/tools.py index 6a559e7..c37978f 100644 --- a/backend/app/assistant/tools.py +++ b/backend/app/assistant/tools.py @@ -68,6 +68,13 @@ "list_tags", "The person's existing tags, most used first. Look here before proposing a tag.", {"type": "object", "properties": {}}), + ToolSpec( + "web_search", + "Search the web, for what the library cannot say: who someone is, when something " + "happened, what a term means. Returns titles, links and short passages. Use it to " + "inform a proposal or an answer, and say where a fact came from; it is not a way to " + "find the person's own assets (use search_assets).", + {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}), ToolSpec( "propose_changes", "Propose edits to one asset's name, description, summary or tags. Nothing is changed " @@ -94,7 +101,10 @@ ] # Tools that only look. The worker reports a step for the rest. -READS = frozenset({"search_assets", "get_asset", "read_transcript", "list_tags"}) +READS = frozenset({"search_assets", "get_asset", "read_transcript", "list_tags", "web_search"}) + +# What a web search may hand back to the model in one call. A page of results, not a crawl. +MAX_WEB_CHARS = 6_000 def _fail(message: str) -> tuple: @@ -122,6 +132,9 @@ def __init__(self, user_id: str, open_session: Callable[[], ContextManager[Sessi self._step = on_step # asset_id -> the proposal's entry for it, in the order they were first proposed. self.items: Dict[str, dict] = {} + # Set by the worker once it holds a connection to the suite: `query -> results`. + # Unset, the tool says so instead of failing. + self.search: Optional[Callable[[str], list]] = None # ─── dispatch ──────────────────────────────────────────────────────────── @@ -132,6 +145,7 @@ def handlers(self) -> Dict[str, Callable[[dict], object]]: "get_asset": self.get_asset, "read_transcript": self.read_transcript, "list_tags": self.list_tags, + "web_search": self.web_search, "propose_changes": self.propose_changes, "propose_enrichment": self.propose_enrichment, } @@ -227,6 +241,22 @@ def read_transcript(self, args: dict) -> object: used += len(line) + 1 return "\n".join(lines) + def web_search(self, args: dict) -> object: + query = _clean(args.get("query")) + if not query: + return _fail("Give a query to search the web for.") + if self.search is None: + return _fail("Web search is not available right now.") + self._step("search", f"Searching the web: {query!r}") + try: + results = self.search(query) + except Exception as exc: # noqa: BLE001 - a failed search is information for the model + return _fail(f"The web search failed: {exc}") + from gecko_suite_client import format_results + + text = format_results(results) + return text[:MAX_WEB_CHARS] + ("\n[cut off]" if len(text) > MAX_WEB_CHARS else "") + def list_tags(self, args: dict) -> object: with self._session() as session: counts = tag_service.usage_counts(session, self.user_id) diff --git a/backend/app/assistant/worker.py b/backend/app/assistant/worker.py index 41e821a..3f4bb38 100644 --- a/backend/app/assistant/worker.py +++ b/backend/app/assistant/worker.py @@ -16,7 +16,7 @@ from contextlib import closing from typing import List, Optional, Tuple -from gecko_suite_client import Stopped, run_agent +from gecko_suite_client import Stopped, run_agent, web_search from sqlmodel import Session, func, select from app import suite @@ -70,6 +70,7 @@ def _run(turn_id: str) -> None: steps="[]", reply="", proposal=None, outcome="", cost=None) chat = session.get(AssistantSession, turn.session_id) user_id, prompt, focus_id = turn.user_id, turn.prompt, turn.focus_asset_id + persona_id = turn.persona_id history = history_for(chat, turn_id) if chat else [] chat_id = turn.session_id focus = None @@ -105,7 +106,7 @@ def on_reply(reply) -> None: working = T.Working(user_id, queue.session, on_step) try: with closing(suite.client(user_id)) as connection: - chat_model = suite.assistant_chat(connection, f"gam:assistant:{chat_id}") + chat_model = suite.assistant_chat(connection, f"gam:assistant:{chat_id}", persona_id) model = chat_model.resolve() if model is None: raise suite.ProviderError("No AI model is set up for the assistant. Choose " @@ -114,6 +115,9 @@ def on_reply(reply) -> None: raise suite.ProviderError( f"{model.get('label') or model.get('model_id')} can't use tools, which the " "assistant needs. Route chat.assistant to another model in Gecko Suite.") + # The web, through the same connection, credited to this conversation. + working.search = lambda query: web_search( + connection, query, subject_ref=f"gam:assistant:{chat_id}") conversation = chat_model.conversation( system=[prompts.REFERENCE, prompts.context(focus=focus, library_size=size)], tools=T.SPECS, history=history, prompt=prompt) @@ -171,9 +175,9 @@ def _finish(turn_id: str, *, message: str, steps: List[dict], **fields) -> None: def submit(session: Session, chat: AssistantSession, *, user_id: str, prompt: str, - focus_asset_id: Optional[str]) -> AssistantTurn: + focus_asset_id: Optional[str], persona_id: Optional[str] = None) -> AssistantTurn: turn = AssistantTurn(id=str(uuid.uuid4()), session_id=chat.id, user_id=user_id, - prompt=prompt, focus_asset_id=focus_asset_id) + prompt=prompt, focus_asset_id=focus_asset_id, persona_id=persona_id) append_message(chat, "user", prompt, turn.id) if not chat.title: chat.title = prompt.strip().splitlines()[0][:80] diff --git a/backend/app/models/assistant.py b/backend/app/models/assistant.py index 50091c9..ed63474 100644 --- a/backend/app/models/assistant.py +++ b/backend/app/models/assistant.py @@ -49,6 +49,10 @@ class AssistantTurn(SQLModel, table=True): # The asset open in the library when the turn was asked, if any: "this" in a request # means it. focus_asset_id: Optional[str] = None + # The suite persona this turn was asked as, if any: its system prompt goes first and its + # model for chat.assistant replaces the routed one. Kept on the turn, not read afresh, so + # a turn that a restart reruns is asked as the same persona. + persona_id: Optional[str] = None # JSON: [{"kind": "think"|"search"|"read"|"propose"|"issue", "text"}] steps: str = "[]" reply: str = "" diff --git a/backend/app/routers/assistant.py b/backend/app/routers/assistant.py index e67bd34..500f466 100644 --- a/backend/app/routers/assistant.py +++ b/backend/app/routers/assistant.py @@ -59,6 +59,7 @@ class TurnRead(BaseModel): error: Optional[str] = None prompt: str focus_asset_id: Optional[str] = None + persona_id: Optional[str] = None steps: List[dict] = [] reply: str = "" proposal: Optional[dict] = None @@ -82,6 +83,9 @@ class SessionDetail(SessionRead): class TurnCreate(BaseModel): prompt: str = Field(min_length=1, max_length=MAX_PROMPT) focus_asset_id: Optional[str] = None + # One of the suite's personas (`GET /personas`), or none. The suite is the authority on + # whether it exists and may be used: an unknown one ends the turn with its sentence. + persona_id: Optional[str] = Field(default=None, max_length=64) class ApplyBody(BaseModel): @@ -100,7 +104,7 @@ def _turn_read(row: AssistantTurn) -> TurnRead: return TurnRead( id=row.id, session_id=row.session_id, status=row.status, stage=row.stage, detail=row.detail, error=row.error_message, prompt=row.prompt, - focus_asset_id=row.focus_asset_id, steps=json.loads(row.steps or "[]"), + focus_asset_id=row.focus_asset_id, persona_id=row.persona_id, steps=json.loads(row.steps or "[]"), reply=row.reply, proposal=apply_service.decode(row.proposal), outcome=row.outcome, cost=row.cost, created_at=row.created_at.isoformat()) @@ -168,6 +172,28 @@ def _require_ready(user_id: str) -> None: "Route chat.assistant to another model in Gecko Suite.") +class PersonaRead(BaseModel): + id: str + name: str + description: str = "" + avatar: Optional[str] = None + + +@router.get("/personas", response_model=ListResponse[PersonaRead]) +def list_personas(user: CurrentUser) -> ListResponse[PersonaRead]: + """The personas the person may ask as: the suite's, for GAM and for every app. + + Only what a picker shows (never the system prompt). Empty when the suite has none or is + not there, and then the panel simply has no picker. + """ + if not suite.enabled(): + return ListResponse(data=[], total=0, limit=0, offset=0) + rows = [PersonaRead(id=p["id"], name=p["name"], description=p.get("description") or "", + avatar=p.get("avatar")) for p in suite.personas(user.id) + if isinstance(p, dict) and p.get("id") and p.get("name")] + return ListResponse(data=rows, total=len(rows), limit=len(rows), offset=0) + + # ─── conversations ─────────────────────────────────────────────────────────── @@ -243,7 +269,8 @@ def start_turn(session_id: str, body: TurnCreate, user: CurrentUser, asset = session.get(Asset, body.focus_asset_id) # Not an error when it isn't theirs: the turn simply has nothing in view. focus = asset.id if asset is not None and asset.user_id == user.id else None - turn = worker.submit(session, chat, user_id=user.id, prompt=prompt, focus_asset_id=focus) + turn = worker.submit(session, chat, user_id=user.id, prompt=prompt, focus_asset_id=focus, + persona_id=body.persona_id or None) return DataResponse(data=_turn_read(turn)) diff --git a/backend/app/suite.py b/backend/app/suite.py index c3aee06..12cc606 100644 --- a/backend/app/suite.py +++ b/backend/app/suite.py @@ -152,12 +152,30 @@ def build_llm(user_id: str, task: str = DEFAULT_TASK, # ─── the assistant ─────────────────────────────────────────────────────────── +# `READINESS_TIMEOUT` is defined under "speech" below; both are short calls on a path a +# person is waiting on. + # The job the assistant's conversation names, so it can be given a model of its own. ASSISTANT_TASK = "chat.assistant" -def assistant_chat(connection: SuiteClient, subject: Optional[str]) -> SuiteChat: - return SuiteChat(connection, task=ASSISTANT_TASK, app=APP, subject_ref=subject) +def assistant_chat(connection: SuiteClient, subject: Optional[str], + persona_id: Optional[str] = None) -> SuiteChat: + return SuiteChat(connection, task=ASSISTANT_TASK, app=APP, subject_ref=subject, + persona_id=persona_id) + + +def personas(user_id: str) -> list[dict]: + """The personas this person may ask the assistant as: the suite's, for GAM and for every + app. Empty for any reason at all (the suite down, none made): a picker with nothing in + it is not shown, and the assistant works without one.""" + try: + with closing(client(user_id, timeout=READINESS_TIMEOUT)) as connection: + rows = connection.request("GET", "/api/v1/personas", params={"app": APP}) + except SuiteError as exc: + logger.info("No personas from Gecko Suite: %s", sentence(exc)) + return [] + return rows if isinstance(rows, list) else [] # ─── speech ────────────────────────────────────────────────────────────────── diff --git a/backend/tests/test_assistant.py b/backend/tests/test_assistant.py index 23acc10..35d87d2 100644 --- a/backend/tests/test_assistant.py +++ b/backend/tests/test_assistant.py @@ -46,6 +46,10 @@ def __init__(self): self.headers: List[Dict[str, str]] = [] self.default: Optional[dict] = model() self.down = False + self.searches: List[dict] = [] + self.search_down = False + self.persona_queries: List[dict] = [] + self.personas: List[dict] = [] def handler(self, request: httpx.Request) -> httpx.Response: if self.down: @@ -56,6 +60,18 @@ def handler(self, request: httpx.Request) -> httpx.Response: if task == "embed.text": return httpx.Response(200, json={"data": {"model": None}}) return httpx.Response(200, json={"data": {"model": self.default}}) + if request.url.path == "/api/v1/search/web": + body = json.loads(request.content) + self.searches.append(body) + if self.search_down: + return httpx.Response(502, json={"detail": {"code": "upstream_error", "message": "search is down"}}) + return httpx.Response(200, json={"data": {"results": [ + {"title": "Giordano, a profile", "url": "https://example.test/g", + "snippet": "Born in 1950 in Naples."}]}}) + if request.url.path == "/api/v1/personas": + self.persona_queries.append(dict(request.url.params)) + return httpx.Response(200, json={"data": self.personas, "total": len(self.personas), + "limit": 0, "offset": 0}) if request.url.path == "/api/v1/ai/chat": self.chats.append(json.loads(request.content)) assert self.script, "the suite was asked more often than the script allows" @@ -346,3 +362,117 @@ def test_deleting_a_conversation_removes_its_turns(library, session, fake_suite) assert library.delete(f"/api/assistant/sessions/{sid}").status_code == 204 assert len(session.exec(select(AssistantTurn)).all()) == 0 assert library.get(f"/api/assistant/sessions/{sid}").status_code == 404 + + +# ─── the web ───────────────────────────────────────────────────────────────── + + +def test_the_assistant_can_search_the_web_and_that_changes_nothing(library, session, fake_suite): + asset = an_asset(library, session) + original = asset.name + fake_suite.script += [ + reply("", ("web_search", {"query": "Giordano neuroweapons"})), + reply("He was born in Naples, per the web."), + ] + sid = new_session(library) + done = run(library, sid, "Who is Giordano?") + + assert done["status"] == "done" and done["proposal"] is None + # Asked of the suite as GAM for the person, credited to this conversation. + (search,) = fake_suite.searches + assert search["query"] == "Giordano neuroweapons" and search["app"] == "gam" + assert search["subject_ref"] == f"gam:assistant:{sid}" + assert fake_suite.headers[-1].get("x-on-behalf-of") == TEST_USER + # The model was given the results to read, numbered so it can cite them. + result = [r for m in fake_suite.chats[1]["messages"] if m["role"] == "tool" for r in m["results"]][0] + assert result["is_error"] is False and "[1] Giordano, a profile" in result["content"] + assert "https://example.test/g" in result["content"] + assert any(s["kind"] == "search" and "web" in s["text"] for s in done["steps"]) + # And it offered the tool, and said when to use it. + first = fake_suite.chats[0] + assert "web_search" in [t["name"] for t in first["tools"]] + assert "web_search" in first["system"][0] + session.refresh(asset) + assert asset.name == original + + +def test_a_search_that_fails_is_something_for_the_model_to_read_not_a_failed_turn( + library, fake_suite): + fake_suite.search_down = True + fake_suite.script += [ + reply("", ("web_search", {"query": "anything"})), + reply("I couldn't search the web just now."), + ] + done = run(library, new_session(library), "Look something up") + assert done["status"] == "done" and "couldn't search" in done["reply"] + result = [r for m in fake_suite.chats[1]["messages"] if m["role"] == "tool" for r in m["results"]][0] + assert result["is_error"] is True and "search failed" in result["content"] + + +def test_an_empty_query_is_refused_without_asking_the_suite(library, fake_suite): + fake_suite.script += [reply("", ("web_search", {"query": " "})), reply("Never mind.")] + run(library, new_session(library)) + assert fake_suite.searches == [] + + +# ─── personas ──────────────────────────────────────────────────────────────── + +PERSONAS = [ + {"id": "p-arch", "name": "Archivist", "description": "Careful and literal.", + "avatar": None, "system_prompt": "SECRET PROMPT", "scope": "instance", "app": None, + "model_overrides": {}, "sort_order": 0, "writable": False}, + {"id": "p-gam", "name": "Librarian", "description": "", "avatar": "https://example.test/a.png", + "system_prompt": "x", "scope": "user", "app": "gam", "model_overrides": {}, "sort_order": 1, + "writable": True}, +] + + +def test_the_picker_lists_the_suites_personas_without_their_prompts(library, fake_suite): + fake_suite.personas = PERSONAS + res = library.get("/api/assistant/personas") + assert res.status_code == 200 + assert [p["name"] for p in res.json()["data"]] == ["Archivist", "Librarian"] + # Only what a picker shows: the system prompt is the suite's to keep. + assert "SECRET PROMPT" not in res.text and "system_prompt" not in res.text + # Asked for GAM's, which the suite narrows to GAM's and everyone's. + assert fake_suite.persona_queries == [{"app": "gam"}] + + +def test_no_personas_when_the_suite_has_none_or_is_down_or_not_in_use(library, fake_suite, monkeypatch): + assert library.get("/api/assistant/personas").json()["data"] == [] + fake_suite.personas = PERSONAS + fake_suite.down = True + assert library.get("/api/assistant/personas").json()["data"] == [] + fake_suite.down = False + monkeypatch.setattr(settings, "ai_backend", "local") + assert library.get("/api/assistant/personas").json()["data"] == [] + + +def test_a_turn_asked_as_a_persona_is_sent_to_the_suite_as_that_persona(library, fake_suite): + fake_suite.script += [reply("As the archivist: no."), reply("Still the archivist.")] + sid = new_session(library) + first = run(library, sid, "Is this the original?", persona_id="p-arch") + assert first["persona_id"] == "p-arch" + assert fake_suite.chats[0]["persona_id"] == "p-arch" + # Asked as another or none, the next turn is: a persona belongs to the turn. + second = run(library, sid, "And now?") + assert second["persona_id"] is None and "persona_id" not in fake_suite.chats[1] + + +def test_a_persona_the_suite_refuses_ends_the_turn_with_its_sentence(library, fake_suite, monkeypatch): + def refuse(request): + return httpx.Response(404, json={"detail": {"code": "not_found", "message": "persona not found"}}) + + sid = new_session(library) + turn = ask(library, sid, "Hello", persona_id="gone") + original = fake_suite.handler + + def handler(request): + if request.url.path == "/api/v1/ai/chat": + return refuse(request) + return original(request) + + monkeypatch.setattr(suite, "TRANSPORT", httpx.MockTransport(handler)) + worker._run(turn["id"]) + done = library.get(f"/api/assistant/turns/{turn['id']}").json()["data"] + assert done["status"] == "error" and "persona not found" in done["error"] diff --git a/frontend/src/api/assistant.ts b/frontend/src/api/assistant.ts index 2c8bef4..ae689f1 100644 --- a/frontend/src/api/assistant.ts +++ b/frontend/src/api/assistant.ts @@ -19,6 +19,13 @@ export interface AssistantStatus { message: string | null } +export interface AssistantPersona { + id: string + name: string + description: string + avatar: string | null +} + export interface AssistantSession { id: string title: string @@ -72,6 +79,8 @@ export interface AssistantTurn { error: string | null prompt: string focus_asset_id: string | null + /** The suite persona this turn was asked as, if any. */ + persona_id: string | null steps: AssistantStep[] reply: string proposal: Proposal | null @@ -128,15 +137,24 @@ export const assistantApi = { return client.delete(`/assistant/sessions/${id}`).then(() => undefined) }, + /** The personas the person may ask as (the suite's). Empty when there are none. */ + personas(): Promise { + return client + .get>('/assistant/personas') + .then((r) => r.data.data) + }, + ask( sessionId: string, prompt: string, - focusAssetId?: string | null + focusAssetId?: string | null, + personaId?: string | null ): Promise { return client .post>(`/assistant/sessions/${sessionId}/turns`, { prompt, focus_asset_id: focusAssetId ?? null, + persona_id: personaId ?? null, }) .then((r) => r.data.data) }, diff --git a/frontend/src/components/AssistantPanel.test.tsx b/frontend/src/components/AssistantPanel.test.tsx index a96c293..f394f9d 100644 --- a/frontend/src/components/AssistantPanel.test.tsx +++ b/frontend/src/components/AssistantPanel.test.tsx @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as configModule from '@/api/config' import { assistantApi, + type AssistantPersona, type AssistantStatus, type AssistantTurn, type ProposalItem, @@ -41,6 +42,7 @@ function turn(overrides: Partial = {}): AssistantTurn { error: null, prompt: 'Tidy these', focus_asset_id: null, + persona_id: null, steps: [], reply: 'I would rename two.', proposal: null, @@ -51,8 +53,13 @@ function turn(overrides: Partial = {}): AssistantTurn { } } -async function show(status: AssistantStatus, turns: AssistantTurn[] = []) { +async function show( + status: AssistantStatus, + turns: AssistantTurn[] = [], + personas: AssistantPersona[] = [] +) { vi.spyOn(assistantApi, 'status').mockResolvedValue(status) + vi.spyOn(assistantApi, 'personas').mockResolvedValue(personas) const sessions = turns.length ? [{ id: 's1', title: 'Tidy these', created_at: '', updated_at: '' }] : [] @@ -187,4 +194,35 @@ describe('AssistantPanel', () => { await waitFor(() => expect(ask).toHaveBeenCalled()) expect(ask.mock.calls[0][1]).toBe('find the harbour\nmore') }) + + it('offers a persona picker only when the suite has personas, and asks as the one chosen', async () => { + vi.spyOn(assistantApi, 'createSession').mockResolvedValue({ + id: 's1', + title: '', + created_at: '', + updated_at: '', + }) + const ask = vi + .spyOn(assistantApi, 'ask') + .mockResolvedValue(turn({ status: 'queued' })) + vi.spyOn(assistantApi, 'getTurn').mockResolvedValue(turn({ status: 'queued' })) + await show( + READY, + [], + [{ id: 'p-arch', name: 'Archivist', description: 'Careful.', avatar: null }] + ) + const picker = await screen.findByRole('combobox', { name: /persona/i }) + await userEvent.selectOptions(picker, 'p-arch') + const box = await screen.findByRole('textbox', { name: /message/i }) + await waitFor(() => expect(box).toBeEnabled()) + await userEvent.type(box, 'Is this the original?{Enter}') + await waitFor(() => expect(ask).toHaveBeenCalled()) + expect(ask.mock.calls[0][3]).toBe('p-arch') + }) + + it('has no picker when there are no personas', async () => { + await show(READY) + await screen.findByRole('textbox', { name: /message/i }) + expect(screen.queryByRole('combobox', { name: /persona/i })).toBeNull() + }) }) diff --git a/frontend/src/components/AssistantPanel.tsx b/frontend/src/components/AssistantPanel.tsx index 2889d9a..fa189b7 100644 --- a/frontend/src/components/AssistantPanel.tsx +++ b/frontend/src/components/AssistantPanel.tsx @@ -27,9 +27,20 @@ const STEP_LABEL: Record = { } export default function AssistantPanel() { - const { status, sessions, active, focus, sending, error, applied } = useAssistantStore() + const { + status, + sessions, + personas, + personaId, + active, + focus, + sending, + error, + applied, + } = useAssistantStore() const send = useAssistantStore((s) => s.send) const stop = useAssistantStore((s) => s.stop) + const setPersona = useAssistantStore((s) => s.setPersona) const select = useAssistantStore((s) => s.select) const newChat = useAssistantStore((s) => s.newChat) const remove = useAssistantStore((s) => s.remove) @@ -181,6 +192,22 @@ export default function AssistantPanel() { About: {focus.name}

)} + {personas.length > 0 && ( + + )}