Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/api/routes/buddy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand All @@ -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:
Expand Down
12 changes: 11 additions & 1 deletion src/api/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."
),
)

Expand Down
70 changes: 66 additions & 4 deletions src/onboarding/buddy_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 = [
Expand Down Expand Up @@ -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.

Expand All @@ -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=(
Expand Down Expand Up @@ -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.
Expand All @@ -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
),
Expand Down
57 changes: 57 additions & 0 deletions tests/api/test_buddy.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
92 changes: 92 additions & 0 deletions tests/onboarding/test_buddy_open.py
Original file line number Diff line number Diff line change
Expand Up @@ -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<<<ACTION>>>\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<<<ACTION>>>\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<<<ACT", "ION>>>\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