From 3c6b86dd2ec6cc33dc01350c3fd71518ae4070dd Mon Sep 17 00:00:00 2001 From: Linus Date: Fri, 18 Sep 2026 16:34:19 +0200 Subject: [PATCH] Greet a manager in team mode instead of a new hire The open endpoint knew one reader. A manager opening team mode was welcomed back to their own onboarding and told their team's stalls as if they were theirs, because STATE is the team's attention list and the prompt describes it as the reader's own work in flight. `team_mode` on the open request picks a team system prompt and a team fallback greeting. It says who is reading, that STATE is about other people, that a situation is a fact rather than a judgment of the person, and that the suggested step is a question a manager would ask about their team. The two-part marker format is deliberately identical, so the marker, the held-back suffix and the done payload stay one code path: a second format here would be a second parser to keep in step with this one. The hire prompt and its fallback are unchanged. Closes #194 Co-Authored-By: Claude Opus 5 --- src/api/routes/buddy.py | 4 ++ src/api/schemas.py | 12 +++- src/onboarding/buddy_open.py | 70 ++++++++++++++++++++-- tests/api/test_buddy.py | 57 ++++++++++++++++++ tests/onboarding/test_buddy_open.py | 92 +++++++++++++++++++++++++++++ 5 files changed, 230 insertions(+), 5 deletions(-) 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