From a5657909a9aec5431b061256110b4e84727358a9 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Mon, 3 Aug 2026 19:45:29 -0700 Subject: [PATCH] [eric] chat: follow-up chips in the user's own voice, per-chat, silent until two real exchanges --- backend/apps/agents/agents.py | 14 +++ .../apps/agents/manager/predict_followups.py | 92 +++++++++++++++++++ .../apps/agents/manager/predict_prompts.py | 4 +- backend/tests/test_predict_followups.py | 51 ++++++++++ .../src/app/pages/AgentChat/AgentChat.tsx | 8 ++ .../src/app/pages/AgentChat/FollowupChips.tsx | 82 +++++++++++++++++ 6 files changed, 249 insertions(+), 2 deletions(-) create mode 100644 backend/apps/agents/manager/predict_followups.py create mode 100644 backend/tests/test_predict_followups.py create mode 100644 frontend/src/app/pages/AgentChat/FollowupChips.tsx diff --git a/backend/apps/agents/agents.py b/backend/apps/agents/agents.py index 5544739a..730882c2 100644 --- a/backend/apps/agents/agents.py +++ b/backend/apps/agents/agents.py @@ -67,6 +67,20 @@ async def list_sessions(dashboard_id: str = ""): sessions = agent_manager.get_all_sessions(dashboard_id=dashboard_id or None) return {"sessions": [p_session_list_item(s) for s in sessions]} +@agents.router.get("/sessions/{session_id}/followups") +async def predict_followups_route(session_id: str, count: int = 3): + """Chat-specific next-message suggestions in the user's own voice. Empty until the + conversation has >= 2 real exchanges; always empty rather than erroring.""" + session = agent_manager.sessions.get(session_id) + if not session: + try: + session = await agent_manager.resume_session(session_id) + except ValueError: + raise HTTPException(status_code=404, detail="session not found") + from backend.apps.agents.manager.predict_followups import predict_followups + return {"suggestions": await predict_followups(session, count=max(1, min(count, 5)))} + + @agents.router.get("/predict-prompts") async def predict_prompts_route(count: int = 5): """Guess a few prompts the user might type next, in their own voice, from what they've already diff --git a/backend/apps/agents/manager/predict_followups.py b/backend/apps/agents/manager/predict_followups.py new file mode 100644 index 00000000..9c4601be --- /dev/null +++ b/backend/apps/agents/manager/predict_followups.py @@ -0,0 +1,92 @@ +"""Aux-LLM follow-up prediction for ONE chat: guess the user's next message in THIS conversation, +in their exact voice, from the conversation itself. Sibling of predict_prompts.py (which predicts +across chats from topic history); this one only ever reads the given session. Provider-agnostic +cheap tier; fail-open to [] so the chat renders nothing instead of an error.""" + +import logging +from typing import List + +from typeguard import typechecked + +from backend.apps.agents.core.aux_llm import aux_max_tokens_for +from backend.apps.agents.core.models import AgentSession +from backend.apps.agents.manager.predict_prompts import parse_suggestion_lines +from backend.apps.agents.manager.session.history_compaction import get_branch_messages + +logger = logging.getLogger(__name__) + +MAX_FOLLOWUPS = 3 +# No suggestions until the conversation has a real shape: below two full exchanges any guess is +# generic filler, and the empty-chat starters already cover turn zero. +MIN_EXCHANGES = 2 +# Enough tail to know where the conversation is, small enough to stay a sub-cent aux call. +P_TAIL_MESSAGES = 12 +P_PER_MESSAGE_CAP = 700 + + +@typechecked +def followups_eligible(session: AgentSession) -> bool: + """True once this branch holds >= MIN_EXCHANGES completed user->assistant exchanges.""" + msgs = get_branch_messages(session) + users = sum(1 for m in msgs if m.role == "user" and not getattr(m, "hidden", False)) + assistants = sum(1 for m in msgs if m.role == "assistant") + return min(users, assistants) >= MIN_EXCHANGES + + +def conversation_tail(session: AgentSession) -> str: + lines: List[str] = [] + for m in get_branch_messages(session)[-P_TAIL_MESSAGES:]: + if getattr(m, "hidden", False) or m.role not in ("user", "assistant"): + continue + text = m.content if isinstance(m.content, str) else str(m.content) + if len(text) > P_PER_MESSAGE_CAP: + text = text[:P_PER_MESSAGE_CAP] + "..." + lines.append(f"{'User' if m.role == 'user' else 'Assistant'}: {text}") + return "\n".join(lines) + + +@typechecked +async def predict_followups(session: AgentSession, count: int = MAX_FOLLOWUPS) -> List[str]: + """Up to `count` plausible next messages for THIS chat, in the user's voice. [] on any miss.""" + try: + if not followups_eligible(session): + return [] + from backend.apps.settings.credentials import get_anthropic_client_for_model + from backend.apps.agents.providers.registry import resolve_aux_model + from backend.apps.settings.settings import load_settings + + global_settings = load_settings() + tail = conversation_tail(session) + if not tail: + return [] + aux_model = (await resolve_aux_model(global_settings, preferred_tier="haiku"))[0] + client = get_anthropic_client_for_model(global_settings, aux_model) + + system_prompt = ( + "You predict the next message a user might send in an ONGOING conversation with their " + "AI agent. You never answer or explain; you only produce plausible follow-ups the USER " + "would type next in THIS conversation.\n\n" + "Mimic the user's exact writing style from their messages in the transcript: their " + "casing, punctuation, brevity, slang. If they write lowercase two-word asks, so do you.\n\n" + f"Return exactly {count} follow-ups, one per line, no numbering, no quotes, no preamble. " + "Each under ~80 characters, each a DIFFERENT direction (dig deeper, next step, adjacent " + "ask), each specific to this conversation's actual content, never generic." + ) + user_turn = ( + "Conversation so far:\n\n" + tail + "\n\n\n" + f"Predict {count} messages this user might send next." + ) + + chunks: List[str] = [] + async with client.messages.stream( + model=aux_model, + max_tokens=aux_max_tokens_for(aux_model, base=200), + system=system_prompt, + messages=[{"role": "user", "content": user_turn}], + ) as stream: + async for text in stream.text_stream: + chunks.append(text) + return parse_suggestion_lines("".join(chunks), count) + except Exception as e: + logger.info(f"[predict-followups] fail-open ([]): {e}") + return [] diff --git a/backend/apps/agents/manager/predict_prompts.py b/backend/apps/agents/manager/predict_prompts.py index dc478ceb..fa316efd 100644 --- a/backend/apps/agents/manager/predict_prompts.py +++ b/backend/apps/agents/manager/predict_prompts.py @@ -46,7 +46,7 @@ def p_recent_topics(limit: int = MAX_TOPICS) -> List[str]: return topics -def p_parse_lines(raw: str, count: int) -> List[str]: +def parse_suggestion_lines(raw: str, count: int) -> List[str]: """One suggestion per line; strip bullets/numbering/quotes, drop empties, cap at count.""" out: List[str] = [] for line in raw.splitlines(): @@ -115,7 +115,7 @@ async def predict_prompts(count: int = MAX_SUGGESTIONS) -> List[str]: ) as stream: async for text in stream.text_stream: chunks.append(text) - return p_parse_lines("".join(chunks), count) + return parse_suggestion_lines("".join(chunks), count) except Exception as e: logger.info(f"[predict-prompts] fail-open ([]): {e}") return [] diff --git a/backend/tests/test_predict_followups.py b/backend/tests/test_predict_followups.py new file mode 100644 index 00000000..246f81fd --- /dev/null +++ b/backend/tests/test_predict_followups.py @@ -0,0 +1,51 @@ +"""The turn gate is the product rule (no suggestions until the chat has real shape), so it is +pinned independently of the aux call, which is fail-open and never exercised here.""" + +from backend.apps.agents.core.models import AgentSession, Message +from backend.apps.agents.manager.predict_followups import followups_eligible, conversation_tail + + +def p_session(*roles: str) -> AgentSession: + s = AgentSession(name="t", model="sonnet") + s.messages = [Message(role=r, content=f"m{i}", branch_id="main") for i, r in enumerate(roles)] + return s + + +def test_empty_chat_is_not_eligible(): + assert followups_eligible(p_session()) is False + + +def test_one_exchange_is_not_eligible(): + assert followups_eligible(p_session("user", "assistant")) is False + + +def test_two_exchanges_are_eligible(): + assert followups_eligible(p_session("user", "assistant", "user", "assistant")) is True + + +def test_unanswered_user_spam_is_not_eligible(): + assert followups_eligible(p_session("user", "user", "user", "user")) is False + + +def test_hidden_user_turns_do_not_count(): + s = p_session("user", "assistant", "user", "assistant") + s.messages[2].hidden = True + assert followups_eligible(s) is False + + +def test_tool_noise_does_not_count_as_exchanges(): + s = p_session("user", "tool_call", "tool_result", "assistant", "tool_call", "assistant") + assert followups_eligible(s) is False + + +def test_tail_contains_only_visible_user_assistant_text(): + s = p_session("user", "tool_call", "assistant") + tail = conversation_tail(s) + assert "User: m0" in tail and "Assistant: m2" in tail and "m1" not in tail + + +def test_tail_caps_giant_messages(): + s = p_session("user", "assistant") + s.messages[0].content = "x" * 5000 + tail = conversation_tail(s) + assert len(tail) < 2000 and tail.count("...") >= 1 diff --git a/frontend/src/app/pages/AgentChat/AgentChat.tsx b/frontend/src/app/pages/AgentChat/AgentChat.tsx index ad380fb4..5ff38fbb 100644 --- a/frontend/src/app/pages/AgentChat/AgentChat.tsx +++ b/frontend/src/app/pages/AgentChat/AgentChat.tsx @@ -69,6 +69,7 @@ import ForceStopAgentBar from './ForceStopAgentBar'; import { RateLimitPill } from './shell/RateLimitPill'; import { ContextRecoveredPill } from './shell/ContextRecoveredPill'; import ChatInput, { ChatInputHandle } from './ChatInput'; +import FollowupChips from './FollowupChips'; import ContextDrawer from './shell/ContextDrawer'; import { ErrorSlime } from '@/app/components/feedback/ErrorSlime'; import { ContextPath } from '@/app/components/editor/DirectoryBrowser'; @@ -2434,6 +2435,13 @@ const AgentChat: React.FC = ({ sessionId: sessionIdProp, onClose + handleSend(p)} + /> void; +} + +// Chat-specific follow-ups in the user's own voice, Claude-style chips above the composer. The +// backend stays silent until the chat has >= 2 real exchanges, so rendering [] as nothing IS the +// turn gate; clicking sends immediately (the text is already written the way the user types). +const FollowupChips: React.FC = ({ sessionId, busy, messageCount, enabled, onPick }) => { + const c = useClaudeTokens(); + const [suggestions, setSuggestions] = useState([]); + const fetchSeqRef = useRef(0); + + useEffect(() => { + if (!sessionId || !enabled || busy) { + setSuggestions([]); + return undefined; + } + const seq = ++fetchSeqRef.current; + // Small settle delay so the fetch reads the turn's final transcript, not a mid-commit state. + const timer = setTimeout(async () => { + try { + const tok = (() => { try { return getAuthToken(); } catch { return ''; } })(); + const headers: Record = {}; + if (tok) headers['Authorization'] = `Bearer ${tok}`; + const resp = await fetch(`${API_BASE}/agents/sessions/${sessionId}/followups?count=3`, { headers }); + if (!resp.ok || seq !== fetchSeqRef.current) return; + const data = await resp.json(); + if (seq !== fetchSeqRef.current) return; + setSuggestions(Array.isArray(data.suggestions) + ? data.suggestions.filter((s: unknown): s is string => typeof s === 'string' && !!s) + : []); + } catch { /* fail open: no chips */ } + }, 900); + return () => clearTimeout(timer); + }, [sessionId, enabled, busy, messageCount]); + + if (suggestions.length === 0) return null; + return ( + + {suggestions.map((s) => ( + { setSuggestions([]); onPick(s); }} + sx={{ + px: 1.25, + py: 0.5, + borderRadius: 999, + border: `1px solid ${c.border.medium}`, + bgcolor: c.bg.surface, + color: c.text.secondary, + fontSize: '0.8125rem', + lineHeight: 1.4, + cursor: 'pointer', + userSelect: 'none', + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + transition: 'border-color 0.15s ease, color 0.15s ease, background 0.15s ease', + '&:hover': { borderColor: c.border.strong, color: c.text.primary, bgcolor: c.bg.elevated }, + }} + > + {s} + + ))} + + ); +}; + +export default FollowupChips;