feat(chat): 생각 과정(reasoning·도구 스텝) 토글 + 작성 중 스피너·경과 초 — ABAP_OPENCODE 와 같은 모양. 백엔드가 step 이벤트로 흘림. 0.1.2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
lee-hyeon-cheol
2026-09-21 21:03:51 +09:00
co-authored by Claude Fable 5.1
parent 5ee599fb6b
commit 5277481513
12 changed files with 192 additions and 22 deletions
+11 -2
View File
@@ -134,8 +134,12 @@ class TurnState:
if mid in self.user_message_ids and mid not in self.assistant_message_ids:
return out
pid = props.get("partID", "")
if self.part_types.get(pid) != "text":
return out # reasoning/tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
ptype = self.part_types.get(pid)
if ptype == "reasoning":
out.append(("step", {"id": pid, "kind": "reasoning", "text": props["delta"]}))
return out
if ptype != "text":
return out # tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
delta = props["delta"]
if pid not in self.text_by_part:
self.part_order.append(pid)
@@ -148,6 +152,11 @@ class TurnState:
part = props.get("part") or {}
if part.get("id"):
self.part_types[part["id"]] = part.get("type", "")
if part.get("type") == "tool":
# 도구 호출 진행 — 화면 "생각 과정" 에 이름·상태만. 입력/출력은 안 보냄(크고 사용자 관심 밖)
st = part.get("state") or {}
out.append(("step", {"id": part.get("id", ""), "kind": "tool", "tool": part.get("tool", ""), "status": st.get("status", ""), "title": st.get("title") or ""}))
return out
if part.get("type") != "text" or part.get("synthetic") or part.get("ignored"):
return out
mid = part.get("messageID", "")
+10 -2
View File
@@ -49,7 +49,7 @@ def test_turnstate_snapshot_path_without_delta():
def test_turnstate_ignores_reasoning_and_emits_title():
st = TurnState("s1", "q")
st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == []
assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == [] # 스냅샷은 안 보냄(delta 만)
ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}}
assert st.handle(ev) == [("title", {"title": "MARA 조회"})]
assert st.handle(ev) == [] # 같은 제목 반복 안 보냄
@@ -57,6 +57,14 @@ def test_turnstate_ignores_reasoning_and_emits_title():
assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시
def test_turnstate_tool_part_becomes_step():
st = TurnState("s1", "q")
st.handle(_msg_updated("ma", "assistant"))
ev = {"type": "message.part.updated", "properties": {"part": {"id": "pt1", "messageID": "ma", "sessionID": "s1", "type": "tool", "tool": "sap-icf_get_program_source", "state": {"status": "running", "title": "ZRMEM2016", "input": {"x": 1}}}}}
assert st.handle(ev) == [("step", {"id": "pt1", "kind": "tool", "tool": "sap-icf_get_program_source", "status": "running", "title": "ZRMEM2016"})]
assert st.accumulated() == "" # 도구는 답변 본문에 안 섞임
def _delta(mid, pid, delta, sid="s1", field="text"):
return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}}
@@ -68,7 +76,7 @@ def test_turnstate_real_server_shape_delta_events():
st.handle(_part_updated("mu", "pu", "q"))
st.handle(_msg_updated("ma", "assistant"))
assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == []
assert st.handle(_delta("ma", "pr", "생각")) == [] # reasoning delta 는 안 보냄
assert st.handle(_delta("ma", "pr", "생각")) == [("step", {"id": "pr", "kind": "reasoning", "text": "생각"})]
assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷
assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})]
assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})]