Skip to content

Commit 94c8699

Browse files
committed
feat: improve resume session labels
1 parent 96cc3c9 commit 94c8699

7 files changed

Lines changed: 221 additions & 24 deletions

File tree

src/linuxagent/app/agent.py

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,13 @@
1919
from ..interfaces import CommandSource, UserInterface
2020
from ..services import ChatService, ClusterService, CommandService, MonitoringService
2121
from .direct_command import DirectCommandRunner
22-
from .resume import render_resumed_session, resume_list, session_title
22+
from .resume import (
23+
ResumeSessionItem,
24+
render_resumed_session,
25+
resume_item,
26+
resume_list,
27+
session_title,
28+
)
2329

2430

2531
@dataclass
@@ -175,14 +181,15 @@ async def _handle_resume_command(self, arg: str, thread_id: str) -> str | None:
175181
return None
176182
sessions = self.chat_service.list_sessions()
177183
if not sessions:
178-
await self.ui.print(resume_list(sessions))
184+
await self.ui.print(resume_list([]))
179185
return None
186+
items = await self._resume_items(sessions)
180187
if self.ui.supports_resume_selector():
181-
selected_thread_id = await self.ui.choose_resume_session(sessions)
188+
selected_thread_id = await self.ui.choose_resume_session(items)
182189
if selected_thread_id is not None:
183190
return await self._resume_and_continue(selected_thread_id)
184191
return None
185-
await self.ui.print(resume_list(sessions))
192+
await self.ui.print(resume_list(items))
186193
self._pending_resume_thread_id = ""
187194
return None
188195

@@ -220,6 +227,22 @@ async def _resume_and_continue(self, thread_id: str) -> str | None:
220227
await self._resume_pending_work(selected_thread_id)
221228
return selected_thread_id
222229

230+
async def _resume_items(self, sessions: list[Any]) -> list[ResumeSessionItem]:
231+
items: list[ResumeSessionItem] = []
232+
for session in sessions:
233+
items.append(resume_item(session, status=await self._resume_status(session.thread_id)))
234+
return items
235+
236+
async def _resume_status(self, thread_id: str) -> str:
237+
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
238+
interrupts = await self._interrupts({}, config)
239+
if not interrupts:
240+
return ""
241+
payload = interrupts[0].value
242+
if isinstance(payload, dict) and payload.get("type") == "confirm_file_patch":
243+
return "pending patch"
244+
return "pending confirm"
245+
223246
async def _resume_pending_work(self, thread_id: str) -> None:
224247
config: RunnableConfig = {"configurable": {"thread_id": thread_id}}
225248
state: Any = {}

src/linuxagent/app/resume.py

Lines changed: 45 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,37 @@
22

33
from __future__ import annotations
44

5+
from dataclasses import dataclass
6+
from datetime import datetime
57
from typing import Any
68

79
from ..services import ChatSession
810

911

10-
def resume_list(sessions: list[ChatSession]) -> str:
11-
if not sessions:
12+
@dataclass(frozen=True)
13+
class ResumeSessionItem:
14+
session: ChatSession
15+
status: str = ""
16+
17+
@property
18+
def thread_id(self) -> str:
19+
return self.session.thread_id
20+
21+
@property
22+
def label(self) -> str:
23+
return resume_choice_label(self)
24+
25+
26+
def resume_item(session: ChatSession, *, status: str = "") -> ResumeSessionItem:
27+
return ResumeSessionItem(session=session, status=status)
28+
29+
30+
def resume_list(items: list[ResumeSessionItem]) -> str:
31+
if not items:
1232
return "没有可恢复的会话。"
1333
lines = ["可恢复会话:"]
14-
for index, session in enumerate(sessions, start=1):
15-
lines.append(f"{index}. {session.title} ({len(session.messages)} messages)")
34+
for index, item in enumerate(items, start=1):
35+
lines.append(f"{index}. {resume_choice_label(item)}")
1636
lines.append("输入编号恢复会话;直接继续提问则保持当前新对话。")
1737
return "\n".join(lines)
1838

@@ -30,6 +50,14 @@ def session_title(messages: list[Any]) -> str:
3050
return "Untitled session"
3151

3252

53+
def resume_choice_label(item: ResumeSessionItem) -> str:
54+
prefix = f"[{item.status}] " if item.status else ""
55+
title = _compact(item.session.title, 48)
56+
updated = _time_label(item.session.updated_at)
57+
count = len(item.session.messages)
58+
return f"{prefix}{updated} {title} · {count} messages"
59+
60+
3361
def _session_preview(messages: list[Any]) -> str:
3462
tail = messages[-6:]
3563
lines: list[str] = []
@@ -42,3 +70,16 @@ def _session_preview(messages: list[Any]) -> str:
4270

4371
def _display_role(role: str) -> str:
4472
return {"human": "你", "ai": "LinuxAgent"}.get(role, role)
73+
74+
75+
def _compact(text: str, limit: int) -> str:
76+
normalized = " ".join(text.split())
77+
return normalized if len(normalized) <= limit else f"{normalized[: limit - 3]}..."
78+
79+
80+
def _time_label(value: datetime) -> str:
81+
local_value = value.astimezone()
82+
now = datetime.now().astimezone()
83+
if local_value.date() == now.date():
84+
return local_value.strftime("%H:%M")
85+
return local_value.strftime("%m-%d %H:%M")

src/linuxagent/services/chat_service.py

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import os
77
from dataclasses import dataclass, field
8+
from datetime import UTC, datetime, timedelta
89
from pathlib import Path
910
from typing import Any
1011

@@ -18,6 +19,8 @@ class ChatSession:
1819
thread_id: str
1920
title: str
2021
messages: tuple[BaseMessage, ...]
22+
created_at: datetime
23+
updated_at: datetime
2124

2225

2326
@dataclass
@@ -47,17 +50,19 @@ def replace_session(
4750
title: str | None = None,
4851
) -> None:
4952
trimmed = list(messages[-self.max_messages :])
50-
if thread_id in self._sessions:
51-
self._sessions.pop(thread_id)
53+
now = _now()
54+
existing = self._sessions.get(thread_id)
5255
self._sessions[thread_id] = ChatSession(
5356
thread_id=thread_id,
5457
title=title or _session_title(trimmed),
5558
messages=tuple(trimmed),
59+
created_at=existing.created_at if existing is not None else now,
60+
updated_at=now,
5661
)
5762
self._messages = trimmed
5863

5964
def list_sessions(self, *, limit: int = 10) -> list[ChatSession]:
60-
sessions = list(self._sessions.values())
65+
sessions = sorted(self._sessions.values(), key=lambda session: session.updated_at)
6166
return list(reversed(sessions[-limit:]))
6267

6368
def get_session(self, thread_id: str) -> ChatSession | None:
@@ -75,6 +80,8 @@ def save(self) -> None:
7580
{
7681
"thread_id": session.thread_id,
7782
"title": session.title,
83+
"created_at": session.created_at.isoformat(),
84+
"updated_at": session.updated_at.isoformat(),
7885
"messages": messages_to_dict(list(session.messages)),
7986
}
8087
for session in self._sessions.values()
@@ -107,22 +114,43 @@ def _load_sessions(self, raw_sessions: Any) -> None:
107114
return
108115
self._sessions.clear()
109116
self._messages = []
110-
for raw_session in raw_sessions:
117+
fallback_time = _now()
118+
for index, raw_session in enumerate(raw_sessions):
111119
if isinstance(raw_session, dict):
112-
self._load_session(raw_session)
120+
self._load_session(raw_session, fallback_time + timedelta(microseconds=index))
113121

114-
def _load_session(self, raw_session: dict[str, Any]) -> None:
122+
def _load_session(self, raw_session: dict[str, Any], fallback_time: datetime) -> None:
115123
raw_thread_id = raw_session.get("thread_id")
116124
raw_messages = raw_session.get("messages")
117125
if not isinstance(raw_thread_id, str) or not isinstance(raw_messages, list):
118126
return
119127
messages = messages_from_dict(raw_messages)
120128
raw_title = raw_session.get("title")
121-
self.replace_session(
122-
raw_thread_id,
123-
messages,
124-
title=raw_title if isinstance(raw_title, str) else None,
129+
trimmed = list(messages[-self.max_messages :])
130+
created_at = _parse_time(raw_session.get("created_at")) or fallback_time
131+
updated_at = _parse_time(raw_session.get("updated_at")) or created_at
132+
self._sessions[raw_thread_id] = ChatSession(
133+
thread_id=raw_thread_id,
134+
title=raw_title if isinstance(raw_title, str) else _session_title(trimmed),
135+
messages=tuple(trimmed),
136+
created_at=created_at,
137+
updated_at=updated_at,
125138
)
139+
self._messages = trimmed
140+
141+
142+
def _now() -> datetime:
143+
return datetime.now(UTC)
144+
145+
146+
def _parse_time(value: Any) -> datetime | None:
147+
if not isinstance(value, str):
148+
return None
149+
try:
150+
parsed = datetime.fromisoformat(value)
151+
except ValueError:
152+
return None
153+
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=UTC)
126154

127155

128156
def _session_title(messages: list[BaseMessage]) -> str:

src/linuxagent/ui/console.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
from rich.text import Text
2121

2222
from ..interfaces import UserInterface
23-
from ..services import ChatSession
2423
from .confirmation_renderer import ConfirmationRenderer
2524
from .diff_renderer import diff_line_style, render_unified_diff
2625
from .prompt_session import PromptSessionManager, SlashCommandCompleter
@@ -153,9 +152,14 @@ def _file_patch_approval_response(files: tuple[str, ...]) -> dict[str, Any]:
153152
return {"decision": "yes", "selected_files": list(selected)}
154153

155154

156-
def _resume_choice_label(session: ChatSession) -> str:
157-
title = session.title if len(session.title) <= 72 else f"{session.title[:69]}..."
158-
return f"{title} [{len(session.messages)} messages]"
155+
def _resume_choice_label(session: Any) -> str:
156+
label = getattr(session, "label", None)
157+
if isinstance(label, str):
158+
return label
159+
title = str(getattr(session, "title", "Untitled session"))
160+
messages = tuple(getattr(session, "messages", ()))
161+
compact_title = title if len(title) <= 72 else f"{title[:69]}..."
162+
return f"{compact_title} [{len(messages)} messages]"
159163

160164

161165
async def _wait_for_escape() -> str:

tests/unit/app/test_agent.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ def __init__(
6060
self.cancel_immediately = False
6161
self.resume_choice: str | None = None
6262
self.resume_selector_enabled = False
63+
self.resume_sessions: list[Any] = []
6364

6465
async def input_stream(self):
6566
for item in self._inputs:
@@ -84,7 +85,7 @@ def supports_resume_selector(self) -> bool:
8485
return self.resume_selector_enabled
8586

8687
async def choose_resume_session(self, sessions: list[Any]) -> str | None:
87-
del sessions
88+
self.resume_sessions = list(sessions)
8889
return self.resume_choice
8990

9091

@@ -433,6 +434,7 @@ async def test_resume_continues_pending_interrupt(tmp_path) -> None:
433434

434435
assert ui.interrupts == [{"type": "confirm_command", "command": "ls"}]
435436
assert isinstance(graph.calls[0], Command)
437+
assert "pending confirm" in ui.resume_sessions[0].label
436438
assert "done" in "\n".join(ui.printed)
437439

438440

tests/unit/app/test_resume.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""Resume rendering tests."""
2+
3+
from __future__ import annotations
4+
5+
from datetime import datetime
6+
7+
from langchain_core.messages import AIMessage, HumanMessage
8+
9+
from linuxagent.app.resume import resume_choice_label, resume_item, resume_list
10+
from linuxagent.services import ChatSession
11+
12+
13+
def test_resume_choice_label_is_compact_and_status_aware() -> None:
14+
session = _session(
15+
title="修改 /tmp/disk_info.sh 并添加 CPU 和 MEM 采集信息到脚本末尾",
16+
message_count=2,
17+
)
18+
label = resume_choice_label(resume_item(session, status="pending confirm"))
19+
20+
assert label.startswith("[pending confirm] ")
21+
assert "修改 /tmp/disk_info.sh" in label
22+
assert "2 messages" in label
23+
assert len(label) < 90
24+
25+
26+
def test_resume_list_uses_compact_labels() -> None:
27+
item = resume_item(_session(title="查看磁盘信息", message_count=1))
28+
29+
rendered = resume_list([item])
30+
31+
assert "1." in rendered
32+
assert "查看磁盘信息" in rendered
33+
assert "1 messages" in rendered
34+
35+
36+
def _session(title: str, message_count: int) -> ChatSession:
37+
messages = [HumanMessage(content=title)]
38+
if message_count > 1:
39+
messages.append(AIMessage(content="done"))
40+
now = datetime.now().astimezone()
41+
return ChatSession(
42+
thread_id="thread",
43+
title=title,
44+
messages=tuple(messages),
45+
created_at=now,
46+
updated_at=now,
47+
)

tests/unit/services/test_services.py

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,10 @@
44

55
import asyncio
66
import json
7+
from datetime import UTC, datetime
78

89
import pytest
9-
from langchain_core.messages import AIMessage, HumanMessage
10+
from langchain_core.messages import AIMessage, HumanMessage, messages_to_dict
1011

1112
from linuxagent.config.models import ClusterConfig, ClusterHost, MonitoringConfig
1213
from linuxagent.intelligence import CommandLearner
@@ -93,6 +94,57 @@ def test_chat_service_saves_named_resume_sessions(tmp_path) -> None:
9394
]
9495

9596

97+
def test_chat_service_persists_session_times_and_sorts_by_updated_at(tmp_path) -> None:
98+
path = tmp_path / "history.json"
99+
older = datetime(2026, 4, 29, 10, 0, tzinfo=UTC)
100+
newer = datetime(2026, 4, 30, 10, 0, tzinfo=UTC)
101+
payload = {
102+
"version": 2,
103+
"sessions": [
104+
_history_session("older", "old task", older),
105+
_history_session("newer", "new task", newer),
106+
],
107+
}
108+
path.write_text(json.dumps(payload), encoding="utf-8")
109+
110+
loaded = ChatService(path, max_messages=10)
111+
loaded.load()
112+
sessions = loaded.list_sessions()
113+
114+
assert [session.thread_id for session in sessions] == ["newer", "older"]
115+
assert sessions[0].created_at == newer
116+
assert sessions[0].updated_at == newer
117+
118+
119+
def test_chat_service_migrates_undated_sessions_in_file_order(tmp_path) -> None:
120+
path = tmp_path / "history.json"
121+
payload = {
122+
"version": 2,
123+
"sessions": [
124+
_history_session("thread-a", "old task", None),
125+
_history_session("thread-b", "new task", None),
126+
],
127+
}
128+
path.write_text(json.dumps(payload), encoding="utf-8")
129+
130+
loaded = ChatService(path, max_messages=10)
131+
loaded.load()
132+
133+
assert [session.thread_id for session in loaded.list_sessions()] == ["thread-b", "thread-a"]
134+
135+
136+
def _history_session(thread_id: str, content: str, timestamp: datetime | None) -> dict[str, object]:
137+
session: dict[str, object] = {
138+
"thread_id": thread_id,
139+
"title": content,
140+
"messages": messages_to_dict([HumanMessage(content=content)]),
141+
}
142+
if timestamp is not None:
143+
session["created_at"] = timestamp.isoformat()
144+
session["updated_at"] = timestamp.isoformat()
145+
return session
146+
147+
96148
class _FakeSSH:
97149
async def execute_many(self, hosts, command, **kwargs):
98150
del kwargs

0 commit comments

Comments
 (0)