diff --git a/src/api/routes/buddy.py b/src/api/routes/buddy.py index 498a0ce..d8512df 100644 --- a/src/api/routes/buddy.py +++ b/src/api/routes/buddy.py @@ -200,6 +200,9 @@ def buddy_open_stream( Emits ``token`` events carrying the greeting as it arrives and one terminal ``done`` carrying the whole greeting and any suggested action. Degrades to a plain welcome rather than erroring: opening the buddy must never fail the page. + + With ``team_mode`` the reader is a project's manager and ``state`` is their team's + attention list, so both the prompt and that plain welcome address a manager. """ def event_stream() -> Iterator[str]: @@ -209,6 +212,7 @@ def event_stream() -> Iterator[str]: recent=[_to_message(m) for m in body.recent], state=body.state, llm=llm, + team_mode=body.team_mode, ): yield sse_event(event) except LLMUnavailableError as exc: diff --git a/src/api/schemas.py b/src/api/schemas.py index 56782de..7b95a11 100644 --- a/src/api/schemas.py +++ b/src/api/schemas.py @@ -1458,7 +1458,17 @@ class BuddyOpenRequest(BaseModel): default="", description=( "A plain-text snapshot of the hire's current state (pull requests, tasks, " - "competencies) for the greeting to ground itself in." + "competencies) for the greeting to ground itself in. In team mode this is " + "the team's attention list instead." + ), + ) + team_mode: bool = Field( + default=False, + description=( + "True when a project's manager is opening a team conversation. The " + "greeting then addresses a manager about their team — `state` is the " + "team's attention list, not the reader's own onboarding — and the " + "suggested next step is a question about the team." ), ) diff --git a/src/onboarding/buddy_open.py b/src/onboarding/buddy_open.py index 64ea4bc..81a0eb8 100644 --- a/src/onboarding/buddy_open.py +++ b/src/onboarding/buddy_open.py @@ -17,6 +17,11 @@ generated ahead of it — strict JSON cannot be streamed as prose, which is why this uses markers. A marker arrives one chunk at a time, so a partial one must be held back as a candidate, never matched early or emitted. + +A visit can also be opened by a project's manager in team mode, where the reader is +responsible for the people the state describes rather than being one of them. That +swaps the system prompt and the fallback greeting; the two-part format and +everything that parses it are shared. """ import json @@ -28,6 +33,14 @@ _FALLBACK_GREETING = "Welcome back! How can I help with your onboarding today?" +# A manager opening team mode is not coming back to their own onboarding, so the +# hire's welcome is wrong in the one place it is guaranteed to be read: the model +# is unavailable and this is the whole greeting. +_TEAM_FALLBACK_GREETING = ( + "You're in team mode. Ask me who needs your attention, or how somebody on the " + "team is getting on." +) + def _format_recent(recent: list[Message]) -> str: lines = [ @@ -66,12 +79,48 @@ def _format_recent(recent: list[Message]) -> str: "you only read from it." ) +# The same two-part shape, deliberately: the marker, the held-back suffix and the +# done payload are parsed by one code path, so a team greeting that formatted itself +# differently would be a second parser to keep in step. Only the reader changes. +_TEAM_STREAM_SYSTEM = ( + "You are a perceptive onboarding buddy greeting a project's manager as they " + "open the chat in team mode. They manage one project and are asking about that " + "project's team -- they are not being onboarded themselves. You keep a private, " + "durable memory note about your conversations with this manager, and you speak " + "to them directly.\n" + "You are given: your MEMORY of these conversations (may be empty on the first " + "visit), the RECENT conversation since you last updated that memory (may be " + "empty), and STATE: the team's current attention list -- who is waiting on " + "somebody else and who has stalled. STATE is about the team, never about the " + "manager reading you.\n" + "Write your reply in exactly two parts, in this order, with nothing before the " + "first part:\n" + "PART 1 -- the greeting, as plain prose with no label and no quotes: a short, " + "first-person opener (2-4 sentences) that greets the manager and says the one " + "thing most worth their attention right now, grounded only in the state and " + "the memory. Be specific, not generic. Never invent facts that are not in the " + "memory or the state. Never welcome them as a new hire, never describe their " + "own onboarding, and never suggest what they should work on. Describe a " + "person's situation as a fact about that situation, never as a judgment of the " + "person -- work waiting on a review is the reviewer's move, not a failing of " + "whoever is waiting on it. The manager reads this as you type it, so it must " + "come first.\n" + f"PART 2 -- the line {_ACTION_MARKER} on its own, then ONE suggested next step " + 'as JSON {"label": short button text, "question": the message to send when the ' + "manager clicks it}, or the word none when nothing fits. The question is one a " + 'manager would ask about their team (for example "Who is blocked right now?" ' + 'or "How is the newest joiner getting on?"), never one about their own work.\n' + "Do not rewrite or restate your memory note. It is maintained separately; here " + "you only read from it." +) + def stream_session( memory: str | None, recent: list[Message], state: str, llm: LLMClient, + team_mode: bool = False, ) -> Iterator[dict[str, object]]: """Stream the greeting as the model writes it. @@ -93,15 +142,28 @@ def stream_session( An unavailable model yields the plain welcome -- opening a visit must never fail the page. + ### Team mode + + ``team_mode`` swaps the system prompt and the fallback, and nothing else. The + reader is a project's manager and ``state`` is their team's attention list, so a + hire's greeting would welcome them back to an onboarding that is not theirs and + describe their team's stalls as their own. The marker, the held-back suffix and + the ``done`` payload are the same code either way -- a second format here would + be a second parser to keep in step with this one. + @param memory: The mentor's durable note, or None on a first visit. Read from, never rewritten here -- see the module docstring. @param recent: The window since the memory was last updated. - @param state: A snapshot of the hire's current state. + @param state: A snapshot of the hire's current state, or in team mode the team's + attention list. + @param team_mode: True when a project's manager is opening a team conversation. @return: ``token`` events carrying the greeting as it arrives, then one terminal ``done`` carrying the whole greeting and any action. """ + system = _TEAM_STREAM_SYSTEM if team_mode else _STREAM_SYSTEM + fallback = _TEAM_FALLBACK_GREETING if team_mode else _FALLBACK_GREETING prompt = [ - Message(role="system", content=_STREAM_SYSTEM), + Message(role="system", content=system), Message( role="user", content=( @@ -155,7 +217,7 @@ def stream_session( greeting += emit yield {"type": "token", "content": emit} except LLMUnavailableError: - yield {"type": "done", "greeting": _FALLBACK_GREETING, "action": None} + yield {"type": "done", "greeting": fallback, "action": None} return # Whatever is still held back was never a marker after all, so it is prose. @@ -169,7 +231,7 @@ def stream_session( # Byte-identical to the concatenated tokens, deliberately. The client renders # the tokens and the caller persists this; if they differed, the message a hire # watched arrive would not be the one they see after a reload. - "greeting": greeting if greeting.strip() else _FALLBACK_GREETING, + "greeting": greeting if greeting.strip() else fallback, "action": ( {"label": label, "question": question} if label and question else None ), diff --git a/tests/api/test_buddy.py b/tests/api/test_buddy.py index 661f636..024aea4 100644 --- a/tests/api/test_buddy.py +++ b/tests/api/test_buddy.py @@ -180,3 +180,60 @@ def test_omitting_both_modes_is_the_hire_mentor(client: TestClient) -> None: persona = response.json()["messages"][0]["content"] assert "the mentor who guides a new hire" in persona assert "manager of one project" not in persona + + +def test_open_stream_takes_team_mode_and_greets_a_manager() -> None: + """The stub echoes one fixed answer, so what the flag changes here is the prompt + it was asked with -- which is the part the route is responsible for threading.""" + recorded: list[str] = [] + + class _RecordingLLM(StubLLMClient): + def stream(self, messages: list[Any]) -> Any: + recorded.append(str(messages[0]["content"])) + return super().stream(messages) + + app.dependency_overrides[get_llm] = lambda: _RecordingLLM( + generate_response="Two people are waiting on a review." + ) + try: + client = TestClient(app) + response = client.post( + "/api/v1/onboarding/buddy/open/stream", + json={ + "memory": None, + "recent": [], + "state": "Who needs attention: ...", + "team_mode": True, + }, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert "Two people are waiting on a review." in response.text + assert "project's manager" in recorded[0] + + +def test_open_stream_without_team_mode_is_the_hire_greeting() -> None: + recorded: list[str] = [] + + class _RecordingLLM(StubLLMClient): + def stream(self, messages: list[Any]) -> Any: + recorded.append(str(messages[0]["content"])) + return super().stream(messages) + + app.dependency_overrides[get_llm] = lambda: _RecordingLLM( + generate_response="Welcome back, Sam!" + ) + try: + client = TestClient(app) + response = client.post( + "/api/v1/onboarding/buddy/open/stream", + json={"memory": None, "recent": [], "state": "1 open PR"}, + ) + finally: + app.dependency_overrides.clear() + + assert response.status_code == 200 + assert "greeting a new hire" in recorded[0] + assert "project's manager" not in recorded[0] diff --git a/tests/onboarding/test_buddy_open.py b/tests/onboarding/test_buddy_open.py index 58067ce..2b79d36 100644 --- a/tests/onboarding/test_buddy_open.py +++ b/tests/onboarding/test_buddy_open.py @@ -228,3 +228,95 @@ def test_the_done_greeting_is_exactly_what_was_streamed() -> None: events = list(stream_session(None, [], "", llm)) assert _done(events)["greeting"] == _tokens(events) + + +# --- Team mode ------------------------------------------------------------- +# +# The reader is a project's manager and STATE is their team's attention list. What +# must not change is everything that parses the model's reply: one format, one +# parser, whoever is reading. + + +def _system_of(prompt: list[Message] | None) -> str: + assert prompt is not None + return str(prompt[0]["content"]) + + +def test_team_mode_greets_a_manager_about_their_team() -> None: + llm = _StreamingStubLLM(["hi"]) + + list(stream_session(memory=None, recent=[], state="", llm=llm, team_mode=True)) + + system = _system_of(llm.last_prompt) + assert "project's manager" in system + assert "the team's current attention list" in system + assert "Never welcome them as a new hire" in system + + +def test_the_hire_greeting_is_untouched_by_default() -> None: + llm = _StreamingStubLLM(["hi"]) + + list(stream_session(memory=None, recent=[], state="", llm=llm)) + + system = _system_of(llm.last_prompt) + assert "greeting a new hire" in system + assert "project's manager" not in system + + +def test_the_team_suggestion_is_a_question_about_the_team() -> None: + llm = _StreamingStubLLM(["hi"]) + + list(stream_session(memory=None, recent=[], state="", llm=llm, team_mode=True)) + + assert "never one about their own work" in _system_of(llm.last_prompt) + + +def test_an_unavailable_model_falls_back_per_mode() -> None: + """The fallback is the one greeting guaranteed to be read in full, so a hire's + welcome would be wrong exactly where it is least recoverable.""" + team = _StreamingStubLLM(LLMUnavailableError("down")) + hire = _StreamingStubLLM(LLMUnavailableError("down")) + + team_done = _done(list(stream_session(None, [], "", team, team_mode=True))) + hire_done = _done(list(stream_session(None, [], "", hire))) + + assert team_done["greeting"] == ( + "You're in team mode. Ask me who needs your attention, or how somebody on " + "the team is getting on." + ) + assert hire_done["greeting"] == ( + "Welcome back! How can I help with your onboarding today?" + ) + + +def test_a_blank_team_greeting_falls_back_to_the_team_welcome() -> None: + llm = _StreamingStubLLM(["\n<<>>\n" + _ACTION]) + + done = _done(list(stream_session(None, [], "", llm, team_mode=True))) + + assert "team mode" in str(done["greeting"]) + + +def test_the_marker_and_the_done_payload_are_identical_in_team_mode() -> None: + llm = _StreamingStubLLM( + ["Two people ", "are waiting on a review.", "\n<<>>\n", _ACTION] + ) + + events = list(stream_session(None, [], "", llm, team_mode=True)) + + assert _tokens(events) == "Two people are waiting on a review." + done = _done(events) + assert done["greeting"] == _tokens(events) + assert done["action"] == { + "label": "What next?", + "question": "What should I work on?", + } + + +def test_a_team_marker_split_across_chunks_is_still_found() -> None: + llm = _StreamingStubLLM(["Nobody is blocked.", "\n<<>>\n", _ACTION]) + + events = list(stream_session(None, [], "", llm, team_mode=True)) + + assert _tokens(events).strip() == "Nobody is blocked." + assert _done(events)["action"] is not None