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}