Skip to content

Commit de5167d

Browse files
bloveclaude
andauthored
feat(examples-chat): write derived thread title on first user message (#242)
Adds a _maybe_write_thread_title side effect to the generate node: on the first user message in a thread (metadata.title absent), the node writes a 50-char slice into LangGraph thread metadata via the official SDK client. Idempotent and error-swallowing so titles never block a run. The threads.service.ts in the demo already prefers metadata.title over the truncated-id fallback, so threads now render with real labels in the drawer after one round-trip per thread. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 7333c9d commit de5167d

2 files changed

Lines changed: 125 additions & 1 deletion

File tree

examples/chat/python/src/graph.py

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121
track-by-id stable.
2222
"""
2323
import json
24+
import os
25+
import re
2426
from typing import Annotated, Literal, Optional
2527
from typing_extensions import TypedDict
2628

@@ -36,7 +38,91 @@
3638
SystemMessage,
3739
ToolMessage,
3840
)
41+
from langchain_core.runnables import RunnableConfig
3942
from langchain_core.tools import tool
43+
from langgraph_sdk import get_client
44+
45+
46+
# Module-level singleton client; created lazily on first thread-title write.
47+
_threads_client = None
48+
49+
50+
def _slice_title(text: str, *, limit: int = 50) -> str:
51+
"""Trim a user message into a thread title.
52+
53+
Replaces internal whitespace runs with single spaces, strips leading
54+
and trailing whitespace, then slices to `limit` codepoints. Regional
55+
indicator pairs (flag emoji) that would be split at the boundary are
56+
trimmed so the slice never ends with an orphaned indicator codepoint.
57+
"""
58+
cleaned = re.sub(r"\s+", " ", text).strip()
59+
if len(cleaned) <= limit:
60+
return cleaned
61+
sliced = cleaned[:limit].rstrip()
62+
# Regional indicators sit in U+1F1E6–U+1F1FF. A flag emoji is exactly
63+
# two consecutive regional indicators. If the slice ends on a regional
64+
# indicator that is the *first* of a pair (i.e. the next codepoint in
65+
# the original string is also a regional indicator, forming a flag), we
66+
# drop it so we never expose a half-flag.
67+
_RI_START = 0x1F1E6
68+
_RI_END = 0x1F1FF
69+
if sliced and _RI_START <= ord(sliced[-1]) <= _RI_END:
70+
pos = len(sliced) - 1
71+
# Check whether the preceding character is also a regional indicator
72+
# (which would make sliced[-1] the *second* of a pair — it's whole).
73+
if pos == 0 or not (_RI_START <= ord(sliced[-2]) <= _RI_END):
74+
# Orphaned first indicator — drop it.
75+
sliced = sliced[:-1].rstrip()
76+
return sliced
77+
78+
79+
async def _maybe_write_thread_title(state: "State", config: RunnableConfig) -> None:
80+
"""Side effect: on the first user message in a thread, persist a
81+
derived title to the thread's LangGraph metadata.
82+
83+
Idempotent — only writes when metadata.title is currently absent.
84+
Errors are swallowed; the title is a UX nicety, never a blocker.
85+
"""
86+
global _threads_client
87+
thread_id = (config.get("configurable") or {}).get("thread_id")
88+
if not isinstance(thread_id, str) or not thread_id:
89+
return
90+
91+
try:
92+
if _threads_client is None:
93+
_threads_client = get_client(
94+
url=os.environ.get("LANGGRAPH_API_URL", "http://localhost:2024"),
95+
)
96+
thread = await _threads_client.threads.get(thread_id)
97+
existing = (thread.get("metadata") or {}).get("title")
98+
if isinstance(existing, str) and existing.strip():
99+
return # Already titled; don't overwrite.
100+
101+
# Find the first user message in the current state.
102+
first_user = None
103+
for m in state.get("messages", []):
104+
type_attr = getattr(m, "type", None)
105+
getter = getattr(m, "_getType", None)
106+
msg_type = type_attr if type_attr else (getter() if callable(getter) else None)
107+
if msg_type == "human":
108+
content = getattr(m, "content", None)
109+
if isinstance(content, str) and content.strip():
110+
first_user = content
111+
break
112+
if not first_user:
113+
return
114+
115+
title = _slice_title(first_user)
116+
if not title:
117+
return
118+
119+
await _threads_client.threads.update(
120+
thread_id,
121+
metadata={"title": title},
122+
)
123+
except Exception:
124+
# Title write must never break the run. Swallow.
125+
return
40126

41127

42128
SYSTEM_PROMPT = (
@@ -294,7 +380,11 @@ class State(TypedDict):
294380
gen_ui_mode: Optional[str]
295381

296382

297-
async def generate(state: State) -> dict:
383+
async def generate(state: State, config: RunnableConfig) -> dict:
384+
# Best-effort thread title write on the first user message. Idempotent;
385+
# swallows errors so it never blocks the run.
386+
await _maybe_write_thread_title(state, config)
387+
298388
model_name = state.get("model") or "gpt-5-mini"
299389
kwargs = {"model": model_name, "streaming": True}
300390
if _is_reasoning_model(model_name):

examples/chat/python/tests/test_graph_smoke.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,37 @@ def test_phase4_artifacts_removed():
125125
"FEEDBACK_FORM_JSONL constant should be removed in Phase 5"
126126
assert not hasattr(mod, "emit_a2ui_surface"), \
127127
"emit_a2ui_surface node should be replaced by emit_generated_surface"
128+
129+
130+
from src.graph import _slice_title
131+
132+
133+
class TestSliceTitle:
134+
def test_short_text_returned_as_is(self):
135+
assert _slice_title("hello world") == "hello world"
136+
137+
def test_long_text_truncated_to_50(self):
138+
text = "a" * 80
139+
result = _slice_title(text)
140+
assert len(result) == 50
141+
assert result == "a" * 50
142+
143+
def test_newlines_replaced_with_spaces(self):
144+
assert _slice_title("hello\nworld") == "hello world"
145+
146+
def test_emoji_not_split_mid_grapheme(self):
147+
# The flag-USA emoji is a 2-codepoint regional-indicator sequence.
148+
# A naive [:50] could land between the two indicators if the
149+
# 50-char boundary falls there. Slice on grapheme boundary so
150+
# the flag stays intact.
151+
text = "x" * 49 + "🇺🇸"
152+
result = _slice_title(text)
153+
# At grapheme boundary 50, the flag is either fully present (51 cps)
154+
# or fully absent (49 'x' chars + truncation). Never mid-flag.
155+
assert "🇺🇸" in result or result == "x" * 49 or result == "x" * 50
156+
157+
def test_empty_string_returns_empty(self):
158+
assert _slice_title("") == ""
159+
160+
def test_strips_leading_trailing_whitespace(self):
161+
assert _slice_title(" hello ") == "hello"

0 commit comments

Comments
 (0)