Skip to content

Commit d4dde6b

Browse files
committed
fix(tui): restore resumed transcript lineage
1 parent 350ee1b commit d4dde6b

11 files changed

Lines changed: 537 additions & 49 deletions

File tree

hermes_state.py

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1132,20 +1132,29 @@ def resolve_resume_session_id(self, session_id: str) -> str:
11321132
current = child_id
11331133
return session_id
11341134

1135-
def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
1135+
def get_messages_as_conversation(
1136+
self, session_id: str, include_ancestors: bool = False
1137+
) -> List[Dict[str, Any]]:
11361138
"""
11371139
Load messages in the OpenAI conversation format (role + content dicts).
11381140
Used by the gateway to restore conversation history.
11391141
"""
1142+
session_ids = [session_id]
1143+
if include_ancestors:
1144+
session_ids = self._session_lineage_root_to_tip(session_id)
1145+
11401146
with self._lock:
1141-
cursor = self._conn.execute(
1142-
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
1143-
"reasoning, reasoning_content, reasoning_details, codex_reasoning_items, "
1144-
"codex_message_items "
1145-
"FROM messages WHERE session_id = ? ORDER BY timestamp, id",
1146-
(session_id,),
1147-
)
1148-
rows = cursor.fetchall()
1147+
rows = []
1148+
for sid in session_ids:
1149+
cursor = self._conn.execute(
1150+
"SELECT role, content, tool_call_id, tool_calls, tool_name, "
1151+
"reasoning, reasoning_content, reasoning_details, codex_reasoning_items, "
1152+
"codex_message_items "
1153+
"FROM messages WHERE session_id = ? ORDER BY timestamp, id",
1154+
(sid,),
1155+
)
1156+
rows.extend(cursor.fetchall())
1157+
11491158
messages = []
11501159
for row in rows:
11511160
msg = {"role": row["role"], "content": row["content"]}
@@ -1185,9 +1194,47 @@ def get_messages_as_conversation(self, session_id: str) -> List[Dict[str, Any]]:
11851194
except (json.JSONDecodeError, TypeError):
11861195
logger.warning("Failed to deserialize codex_message_items, falling back to None")
11871196
msg["codex_message_items"] = None
1197+
if include_ancestors and self._is_duplicate_replayed_user_message(messages, msg):
1198+
continue
11881199
messages.append(msg)
11891200
return messages
11901201

1202+
def _session_lineage_root_to_tip(self, session_id: str) -> List[str]:
1203+
if not session_id:
1204+
return [session_id]
1205+
1206+
chain = []
1207+
current = session_id
1208+
seen = set()
1209+
with self._lock:
1210+
for _ in range(100):
1211+
if not current or current in seen:
1212+
break
1213+
seen.add(current)
1214+
chain.append(current)
1215+
row = self._conn.execute(
1216+
"SELECT parent_session_id FROM sessions WHERE id = ?",
1217+
(current,),
1218+
).fetchone()
1219+
if row is None:
1220+
break
1221+
current = row["parent_session_id"] if hasattr(row, "keys") else row[0]
1222+
return list(reversed(chain)) or [session_id]
1223+
1224+
@staticmethod
1225+
def _is_duplicate_replayed_user_message(messages: List[Dict[str, Any]], msg: Dict[str, Any]) -> bool:
1226+
if msg.get("role") != "user":
1227+
return False
1228+
content = msg.get("content")
1229+
if not isinstance(content, str) or not content:
1230+
return False
1231+
for prev in reversed(messages):
1232+
if prev.get("role") == "user" and prev.get("content") == content:
1233+
return True
1234+
if prev.get("role") == "assistant" and (prev.get("content") or prev.get("tool_calls")):
1235+
return False
1236+
return False
1237+
11911238
# =========================================================================
11921239
# Search
11931240
# =========================================================================

tests/test_hermes_state.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,35 @@ def test_get_messages_as_conversation(self, db):
222222
assert conv[0] == {"role": "user", "content": "Hello"}
223223
assert conv[1] == {"role": "assistant", "content": "Hi!"}
224224

225+
def test_get_messages_as_conversation_includes_ancestor_chain(self, db):
226+
db.create_session("root", "tui")
227+
db.append_message("root", role="user", content="first prompt")
228+
db.append_message("root", role="assistant", content="first answer")
229+
db.create_session("child", "tui", parent_session_id="root")
230+
db.append_message("child", role="user", content="second prompt")
231+
db.append_message("child", role="assistant", content="second answer")
232+
233+
conv = db.get_messages_as_conversation("child", include_ancestors=True)
234+
235+
assert [m["content"] for m in conv] == [
236+
"first prompt",
237+
"first answer",
238+
"second prompt",
239+
"second answer",
240+
]
241+
242+
def test_get_messages_as_conversation_avoids_repeated_resume_prompts_from_ancestors(self, db):
243+
db.create_session("root", "tui")
244+
db.append_message("root", role="user", content="same prompt")
245+
db.append_message("root", role="user", content="same prompt")
246+
db.append_message("root", role="assistant", content="answer")
247+
db.create_session("child", "tui", parent_session_id="root")
248+
db.append_message("child", role="user", content="next prompt")
249+
250+
conv = db.get_messages_as_conversation("child", include_ancestors=True)
251+
252+
assert [m["content"] for m in conv if m["role"] == "user"] == ["same prompt", "next prompt"]
253+
225254
def test_finish_reason_stored(self, db):
226255
db.create_session(session_id="s1", source="cli")
227256
db.append_message("s1", role="assistant", content="Done", finish_reason="stop")

tests/test_tui_gateway_server.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,69 @@ def test_write_json_returns_false_on_broken_pipe(monkeypatch):
5959
assert server.write_json({"ok": True}) is False
6060

6161

62+
def test_history_to_messages_preserves_tool_calls_for_resume_display():
63+
history = [
64+
{"role": "user", "content": "first prompt"},
65+
{
66+
"role": "assistant",
67+
"content": "",
68+
"tool_calls": [
69+
{
70+
"id": "call_1",
71+
"function": {
72+
"name": "search_files",
73+
"arguments": json.dumps({"pattern": "resume"}),
74+
},
75+
}
76+
],
77+
},
78+
{"role": "tool", "content": "{}", "tool_call_id": "call_1"},
79+
{"role": "assistant", "content": "first answer"},
80+
{"role": "user", "content": "second prompt"},
81+
]
82+
83+
assert server._history_to_messages(history) == [
84+
{"role": "user", "text": "first prompt"},
85+
{"context": "resume", "name": "search_files", "role": "tool"},
86+
{"role": "assistant", "text": "first answer"},
87+
{"role": "user", "text": "second prompt"},
88+
]
89+
90+
91+
def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
92+
captured = {}
93+
94+
class FakeDB:
95+
def get_session(self, target):
96+
return {"id": target}
97+
98+
def reopen_session(self, target):
99+
captured["reopened"] = target
100+
101+
def get_messages_as_conversation(self, target, include_ancestors=False):
102+
captured.setdefault("history_calls", []).append((target, include_ancestors))
103+
return [
104+
{"role": "user", "content": "root prompt"},
105+
{"role": "assistant", "content": "root answer"},
106+
] if include_ancestors else [{"role": "user", "content": "tip prompt"}]
107+
108+
monkeypatch.setattr(server, "_get_db", lambda: FakeDB())
109+
monkeypatch.setattr(server, "_enable_gateway_prompts", lambda: None)
110+
monkeypatch.setattr(server, "_set_session_context", lambda target: [])
111+
monkeypatch.setattr(server, "_clear_session_context", lambda tokens: None)
112+
monkeypatch.setattr(server, "_make_agent", lambda *args, **kwargs: types.SimpleNamespace(model="test"))
113+
monkeypatch.setattr(server, "_session_info", lambda agent: {"model": "test", "tools": {}, "skills": {}})
114+
monkeypatch.setattr(server, "_init_session", lambda sid, key, agent, history, cols=80: None)
115+
116+
resp = server.handle_request({"id": "1", "method": "session.resume", "params": {"session_id": "tip"}})
117+
118+
assert resp["result"]["messages"] == [
119+
{"role": "user", "text": "root prompt"},
120+
{"role": "assistant", "text": "root answer"},
121+
]
122+
assert captured["history_calls"] == [("tip", False), ("tip", True)]
123+
124+
62125
def test_status_callback_emits_kind_and_text():
63126
with patch("tui_gateway.server._emit") as emit:
64127
cb = server._agent_cbs("sid")["status_callback"]

tui_gateway/server.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -913,8 +913,16 @@ def _probe_config_health(cfg: dict) -> str:
913913

914914

915915
def _session_info(agent) -> dict:
916+
reasoning_config = getattr(agent, "reasoning_config", None)
917+
reasoning_effort = ""
918+
if isinstance(reasoning_config, dict) and reasoning_config.get("enabled") is not False:
919+
reasoning_effort = str(reasoning_config.get("effort", "") or "")
920+
service_tier = getattr(agent, "service_tier", None) or ""
916921
info: dict = {
917922
"model": getattr(agent, "model", ""),
923+
"reasoning_effort": reasoning_effort,
924+
"service_tier": service_tier,
925+
"fast": service_tier == "priority",
918926
"tools": {},
919927
"skills": {},
920928
"cwd": os.getcwd(),
@@ -1013,7 +1021,7 @@ def _tool_summary(name: str, result: str, duration_s: float | None) -> str | Non
10131021
if n is not None:
10141022
text = f"Extracted {n} {'page' if n == 1 else 'pages'}"
10151023

1016-
return f"{text or 'Completed'}{suffix}" if (text or dur) else None
1024+
return f"{text}{suffix}" if text else None
10171025

10181026

10191027
def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
@@ -1029,10 +1037,13 @@ def _on_tool_start(sid: str, tool_call_id: str, name: str, args: dict):
10291037
pass
10301038
session.setdefault("tool_started_at", {})[tool_call_id] = time.time()
10311039
if _tool_progress_enabled(sid):
1040+
payload = {"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)}
1041+
if name == "todo" and isinstance(args, dict) and isinstance(args.get("todos"), list):
1042+
payload["todos"] = args.get("todos")
10321043
_emit(
10331044
"tool.start",
10341045
sid,
1035-
{"tool_id": tool_call_id, "name": name, "context": _tool_ctx(name, args)},
1046+
payload,
10361047
)
10371048

10381049

@@ -1050,6 +1061,13 @@ def _on_tool_complete(sid: str, tool_call_id: str, name: str, args: dict, result
10501061
summary = _tool_summary(name, result, duration_s)
10511062
if summary:
10521063
payload["summary"] = summary
1064+
if name == "todo":
1065+
try:
1066+
data = json.loads(result)
1067+
if isinstance(data, dict) and isinstance(data.get("todos"), list):
1068+
payload["todos"] = data.get("todos")
1069+
except Exception:
1070+
pass
10531071
try:
10541072
from agent.display import render_edit_diff_with_delta
10551073

@@ -1698,7 +1716,8 @@ def _(rid, params: dict) -> dict:
16981716
try:
16991717
db.reopen_session(target)
17001718
history = db.get_messages_as_conversation(target)
1701-
messages = _history_to_messages(history)
1719+
display_history = db.get_messages_as_conversation(target, include_ancestors=True)
1720+
messages = _history_to_messages(display_history)
17021721
tokens = _set_session_context(target)
17031722
try:
17041723
agent = _make_agent(sid, target, session_id=target)
@@ -1746,11 +1765,20 @@ def _(rid, params: dict) -> dict:
17461765
@method("session.history")
17471766
def _(rid, params: dict) -> dict:
17481767
session, err = _sess(params, rid)
1749-
return err or _ok(
1768+
if err:
1769+
return err
1770+
history = list(session.get("history", []))
1771+
db = _get_db()
1772+
if db is not None and session.get("session_key"):
1773+
try:
1774+
history = db.get_messages_as_conversation(session["session_key"], include_ancestors=True)
1775+
except Exception:
1776+
pass
1777+
return _ok(
17501778
rid,
17511779
{
17521780
"count": len(session.get("history", [])),
1753-
"messages": _history_to_messages(list(session.get("history", []))),
1781+
"messages": _history_to_messages(history),
17541782
},
17551783
)
17561784

ui-tui/src/__tests__/messages.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
import { describe, expect, it } from 'vitest'
22

3+
import { toTranscriptMessages } from '../domain/messages.js'
34
import { upsert } from '../lib/messages.js'
45

6+
describe('toTranscriptMessages', () => {
7+
it('preserves assistant tool-call rows so resume does not drop prior turns', () => {
8+
const rows = [
9+
{ role: 'user', text: 'first prompt' },
10+
{ role: 'tool', context: 'repo', name: 'search_files', text: 'ignored raw result' },
11+
{ role: 'assistant', text: 'first answer' },
12+
{ role: 'user', text: 'second prompt' }
13+
]
14+
15+
expect(toTranscriptMessages(rows).map(msg => [msg.role, msg.text])).toEqual([
16+
['user', 'first prompt'],
17+
['assistant', 'first answer'],
18+
['user', 'second prompt']
19+
])
20+
expect(toTranscriptMessages(rows)[1]?.tools?.[0]).toContain('Search Files')
21+
})
22+
})
23+
524
describe('upsert', () => {
625
it('appends when last role differs', () => {
726
expect(upsert([{ role: 'user', text: 'hi' }], 'assistant', 'hello')).toHaveLength(2)

ui-tui/src/__tests__/text.test.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
import { describe, expect, it } from 'vitest'
22

33
import {
4+
boundedLiveRenderText,
5+
buildToolTrailLine,
46
edgePreview,
57
estimateRows,
68
estimateTokensRough,
79
fmtK,
810
isToolTrailResultLine,
911
lastCotTrailIndex,
12+
parseToolTrailResultLine,
1013
pasteTokenLabel,
11-
sameToolTrailGroup
14+
sameToolTrailGroup,
15+
splitToolDuration
1216
} from '../lib/text.js'
1317

1418
describe('isToolTrailResultLine', () => {
@@ -19,6 +23,16 @@ describe('isToolTrailResultLine', () => {
1923
})
2024
})
2125

26+
describe('buildToolTrailLine', () => {
27+
it('puts completion duration inline before the result marker', () => {
28+
const line = buildToolTrailLine('read_file', 'x', false, '', 0.94)
29+
30+
expect(line).toBe('Read File("x") (0.9s) ✓')
31+
expect(parseToolTrailResultLine(line)).toEqual({ call: 'Read File("x") (0.9s)', detail: '', mark: '✓' })
32+
expect(splitToolDuration('Read File("x") (0.9s)')).toEqual({ label: 'Read File("x")', duration: ' (0.9s)' })
33+
})
34+
})
35+
2236
describe('lastCotTrailIndex', () => {
2337
it('finds last non-result line', () => {
2438
expect(lastCotTrailIndex(['a ✓', 'thinking…'])).toBe(1)
@@ -68,6 +82,28 @@ describe('estimateTokensRough', () => {
6882
})
6983
})
7084

85+
describe('boundedLiveRenderText', () => {
86+
it('preserves short live text verbatim', () => {
87+
expect(boundedLiveRenderText('one\ntwo', { maxChars: 100, maxLines: 10 })).toBe('one\ntwo')
88+
})
89+
90+
it('keeps the live tail by character budget', () => {
91+
const out = boundedLiveRenderText('abcdefghij', { maxChars: 4, maxLines: 10 })
92+
93+
expect(out).toContain('ghij')
94+
expect(out).toContain('omitted')
95+
expect(out).not.toContain('abcdef')
96+
})
97+
98+
it('keeps the live tail by line budget', () => {
99+
const out = boundedLiveRenderText(['a', 'b', 'c', 'd'].join('\n'), { maxChars: 100, maxLines: 2 })
100+
101+
expect(out).toContain('c\nd')
102+
expect(out).toContain('omitted 2 lines')
103+
expect(out).not.toContain('a\nb')
104+
})
105+
})
106+
71107
describe('edgePreview', () => {
72108
it('keeps both ends for long text', () => {
73109
expect(edgePreview('Vampire Bondage ropes slipped from her neck, still stained with blood', 8, 18)).toBe(

0 commit comments

Comments
 (0)