hermes: keep durable worker activity visible

This commit is contained in:
jenkins 2026-08-16 01:10:47 -03:00
parent 4a750c9717
commit 9bc47f9ce3
9 changed files with 579 additions and 37 deletions

View File

@ -856,6 +856,83 @@ function replaceOnce(source, before, after, label) {
"",
"xterm WebGL import",
);
source = replaceOnce(
source,
' const resumeParam = searchParams.get("resume");\n',
' const resumeParam = searchParams.get("resume");\n' +
' const lineageRoot = searchParams.get("lineage_root") || resumeParam;\n',
"durable lineage root",
);
source = replaceOnce(
source,
String.raw` useEffect(() => {
if (!resumeParam) return;
let cancelled = false;
api
.getSessionLatestDescendant(resumeParam, scopedProfile)
.then((res) => {
if (cancelled || !res.session_id || res.session_id === resumeParam) {
return;
}
const next = new URLSearchParams(searchParams);
next.set("resume", res.session_id);
setSearchParams(next, { replace: true });
})
.catch(() => {
// Best-effort: old servers or missing sessions should not block chat.
});
return () => {
cancelled = true;
};
}, [resumeParam, scopedProfile, searchParams, setSearchParams]);
`,
String.raw` useEffect(() => {
if (!lineageRoot) return;
let cancelled = false;
const followActiveLineage = async () => {
try {
const res = await api.getSessionLatestDescendant(
lineageRoot,
scopedProfile,
);
if (cancelled || !res.session_id) return;
setSearchParams(
(previous) => {
const next = new URLSearchParams(previous);
const current = next.get("resume");
if (current === res.session_id) return previous;
next.set("lineage_root", lineageRoot);
next.set("resume", res.session_id);
return next;
},
{ replace: true },
);
} catch {
// Best-effort: old servers or missing sessions should not block chat.
}
};
void followActiveLineage();
const timer = window.setInterval(() => void followActiveLineage(), 2_000);
return () => {
cancelled = true;
window.clearInterval(timer);
};
}, [lineageRoot, scopedProfile, setSearchParams]);
`,
"active lineage follower",
);
source = replaceOnce(
source,
' next.delete("resume");\n',
' next.delete("resume");\n next.delete("lineage_root");\n',
"fresh chat lineage reset",
);
const webglStart = source.indexOf(" // WebGL draws from");
const webglEnd = source.indexOf(
"\n\n // Initial fit + resize observer",
@ -993,6 +1070,40 @@ function replaceOnce(source, before, after, label) {
),`,
"session child query option",
);
source = replaceOnce(
source,
`export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
}`,
`export interface SessionMessagesResponse {
session_id: string;
messages: SessionMessage[];
total_messages?: number;
}`,
"session message total",
);
source = replaceOnce(
source,
` getSessionMessages: (id: string, profile = getManagementProfile()) =>
fetchJSON<SessionMessagesResponse>(
appendProfileParam(\`/api/sessions/\${encodeURIComponent(id)}/messages\`, profile),
),`,
` getSessionMessages: (
id: string,
profile = getManagementProfile(),
limit?: number,
) =>
fetchJSON<SessionMessagesResponse>(
appendProfileParam(
\`/api/sessions/\${encodeURIComponent(id)}/messages\${
limit ? \`?limit=\${Math.max(1, Math.floor(limit))}\` : ""
}\`,
profile,
),
),`,
"bounded session message query",
);
fs.writeFileSync(path, source);
}
@ -1019,6 +1130,18 @@ function replaceOnce(source, before, after, label) {
'.getSessions(SESSION_LIMIT, 0, scopeKey, "recent", true)',
"session-tree child query",
);
source = replaceOnce(
source,
' next.set("resume", id);\n',
' next.delete("lineage_root");\n next.set("resume", id);\n',
"manual session lineage reset",
);
source = replaceOnce(
source,
' next.delete("resume");\n',
' next.delete("resume");\n next.delete("lineage_root");\n',
"new session lineage reset",
);
source = replaceOnce(
source,
" const [reloadNonce, setReloadNonce] = useState(0);\n",
@ -1236,6 +1359,8 @@ RUN cd /opt/hermes/web \
&& grep -Fq 'api.getSessions(1, 0' src/components/ChatSidebar.tsx \
&& grep -Fq 'if (unmounting) return;' src/pages/ChatPage.tsx \
&& grep -Fq 'resume:${resumeParam}' src/pages/ChatPage.tsx \
&& grep -Fq 'followActiveLineage' src/pages/ChatPage.tsx \
&& grep -Fq 'lineage_root' src/components/ChatSessionList.tsx \
&& grep -Fq 'eventsRetryAttempt.current' src/components/ChatSidebar.tsx \
&& grep -Fq 'reconnecting…' src/components/ChatSidebar.tsx \
&& grep -Fq 'redirectToProxyLogin' src/lib/api.ts \

View File

@ -17,6 +17,7 @@ import {
} from "@/lib/api";
const POLL_MS = 2_000;
const DEFAULT_VISIBLE_MESSAGES = 250;
interface SessionActivityPanelProps {
sessionId: string;
@ -29,7 +30,7 @@ function messageLabel(message: SessionMessage): string {
}
if (message.tool_calls?.length) {
const names = message.tool_calls
.map((call) => call.function.name)
.map((call) => call?.function?.name)
.filter(Boolean)
.join(", ");
return names ? `Tool calls: ${names}` : "Tool calls";
@ -37,6 +38,43 @@ function messageLabel(message: SessionMessage): string {
return message.role === "assistant" ? "Hermes" : message.role;
}
function displayText(value: unknown): string {
if (typeof value === "string") return value;
if (value == null) return "";
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value);
}
}
function ExpandablePayload({
payload,
summary,
openByDefault = false,
}: {
payload: string;
summary: string;
openByDefault?: boolean;
}) {
const [open, setOpen] = useState(openByDefault);
return (
<details
className="my-1 rounded border border-white/10 px-2 py-1"
open={open}
onToggle={(event) => setOpen(event.currentTarget.open)}
>
<summary className="cursor-pointer select-none text-white/75">{summary}</summary>
{open && (
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
{payload}
</pre>
)}
</details>
);
}
function latestTimestamp(messages: SessionMessage[]): number | null {
let latest: number | null = null;
for (const message of messages) {
@ -53,8 +91,8 @@ function ActivityMessage({
message: SessionMessage;
index: number;
}) {
const toolCalls = message.tool_calls ?? [];
const content = message.content ?? "";
const toolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
const content = displayText(message.content);
const isToolResult = message.role === "tool";
return (
@ -71,26 +109,23 @@ function ActivityMessage({
)}
</div>
{toolCalls.map((call) => (
<details key={call.id} className="my-1 rounded border border-white/10 px-2 py-1">
<summary className="cursor-pointer select-none text-white/75">
{call.function.name || "tool"} arguments
</summary>
<pre className="mt-2 max-h-72 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] text-white/70">
{call.function.arguments}
</pre>
</details>
))}
{toolCalls.map((call, callIndex) => {
const name = call?.function?.name || "tool";
return (
<ExpandablePayload
key={call?.id || `${index}-${callIndex}`}
payload={displayText(call?.function?.arguments)}
summary={`${name} arguments`}
/>
);
})}
{content && isToolResult ? (
<details className="mt-1" open={content.length < 1_500}>
<summary className="cursor-pointer select-none text-white/75">
{content.length.toLocaleString()} characters of output
</summary>
<pre className="mt-2 max-h-96 overflow-auto whitespace-pre-wrap break-words text-[0.6875rem] leading-relaxed text-white/75">
{content}
</pre>
</details>
<ExpandablePayload
payload={content}
summary={`${content.length.toLocaleString()} characters of output`}
openByDefault={content.length < 1_500}
/>
) : content ? (
<pre className="whitespace-pre-wrap break-words font-sans leading-relaxed text-white/90">
{content}
@ -114,7 +149,9 @@ export function SessionActivityPanel({
}: SessionActivityPanelProps) {
const [detail, setDetail] = useState<SessionInfo | null>(null);
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [totalMessages, setTotalMessages] = useState(0);
const [expanded, setExpanded] = useState(true);
const [visibleLimit, setVisibleLimit] = useState(DEFAULT_VISIBLE_MESSAGES);
const [error, setError] = useState<string | null>(null);
const [pollTime, setPollTime] = useState(Date.now());
const requestRunning = useRef(false);
@ -127,10 +164,16 @@ export function SessionActivityPanel({
try {
const [nextDetail, response] = await Promise.all([
api.getSessionDetail(sessionId, profile ?? ""),
api.getSessionMessages(sessionId, profile ?? ""),
api.getSessionMessages(sessionId, profile ?? "", visibleLimit),
]);
setDetail(nextDetail);
setMessages(Array.isArray(response.messages) ? response.messages : []);
const nextMessages = Array.isArray(response.messages) ? response.messages : [];
setMessages(nextMessages);
setTotalMessages(
typeof response.total_messages === "number"
? response.total_messages
: nextMessages.length,
);
setError(null);
setPollTime(Date.now());
if (stickToBottom.current) {
@ -144,6 +187,10 @@ export function SessionActivityPanel({
} finally {
requestRunning.current = false;
}
}, [profile, sessionId, visibleLimit]);
useEffect(() => {
setVisibleLimit(DEFAULT_VISIBLE_MESSAGES);
}, [profile, sessionId]);
useEffect(() => {
@ -160,6 +207,14 @@ export function SessionActivityPanel({
}, [load]);
const latest = useMemo(() => latestTimestamp(messages), [messages]);
const visibleMessages = useMemo(
() =>
messages.length <= visibleLimit
? messages
: messages.slice(-visibleLimit),
[messages, visibleLimit],
);
const hiddenMessageCount = Math.max(0, totalMessages - visibleMessages.length);
const quietSeconds = latest === null ? null : Math.max(0, pollTime / 1000 - latest);
const running = detail?.ended_at == null;
const status = error
@ -199,7 +254,8 @@ export function SessionActivityPanel({
aria-live="polite"
aria-atomic="true"
>
{status} · {messages.length} recorded events
{status} · {messages.length.toLocaleString()} of{" "}
{totalMessages.toLocaleString()} recorded events loaded
</div>
</div>
<div className="flex items-center gap-2">
@ -245,6 +301,29 @@ export function SessionActivityPanel({
node.scrollHeight - node.scrollTop - node.clientHeight < 80;
}}
>
{hiddenMessageCount > 0 && (
<div className="sticky top-0 z-10 flex justify-center pb-2">
<Button
onClick={() => {
stickToBottom.current = false;
setVisibleLimit((current) => current + DEFAULT_VISIBLE_MESSAGES);
}}
prefix={<ChevronUp className="h-3.5 w-3.5" />}
aria-label={`Show up to ${Math.min(
DEFAULT_VISIBLE_MESSAGES,
hiddenMessageCount,
)} earlier recorded events`}
className="bg-black/95 text-white"
>
Show up to{" "}
{Math.min(
DEFAULT_VISIBLE_MESSAGES,
hiddenMessageCount,
).toLocaleString()}{" "}
earlier events
</Button>
</div>
)}
{messages.length === 0 ? (
<div className="flex min-h-full flex-col items-center justify-center gap-3 text-center text-sm text-white/65">
<Activity className="h-7 w-7 animate-pulse text-warning" aria-hidden />
@ -254,11 +333,11 @@ export function SessionActivityPanel({
</div>
</div>
) : (
messages.map((message, index) => (
visibleMessages.map((message, index) => (
<ActivityMessage
key={`${message.timestamp ?? "untimed"}-${index}`}
key={`${message.timestamp ?? "untimed"}-${hiddenMessageCount + index}`}
message={message}
index={index}
index={hiddenMessageCount + index}
/>
))
)}

View File

@ -279,12 +279,14 @@ data:
## Atlas engineering access
Atlas repositories are private and canonical at
`https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git authentication is
already supplied through `GIT_ASKPASS`. Verify the remote and cleanly
separate pre-existing changes, create a task branch, run the repository's
tests, use `git push --dry-run` when proving access, and push a real branch
only when the requested implementation is review-ready. Never force-push.
The Atlas organization has private visibility. Its repositories are access-
controlled as either private or Gitea-internal, never public, and are
canonical at `https://scm.bstein.dev/atlas/<repo>.git`. HTTPS Git
authentication is already supplied through `GIT_ASKPASS`. Verify the remote
and cleanly separate pre-existing changes, create a task branch, run the
repository's tests, use `git push --dry-run` when proving access, and push a
real branch only when the requested implementation is review-ready. Never
force-push.
The terminal PATH contains the pinned operator tools. Start cluster work
with `kubectl config current-context`, read-only status/events/logs, and the

View File

@ -125,6 +125,7 @@ spec:
- |
set -eu
env_file=/opt/data/.env
profile_file=/opt/data/home/.profile
mkdir -p \
/opt/data/home/.claude \
/opt/data/home/.codex \
@ -184,6 +185,18 @@ spec:
upsert_env GIT_ASKPASS /opt/coordinator/gitea_askpass.sh
upsert_env GIT_TERMINAL_PROMPT 0
chmod 0600 "${env_file}"
touch "${profile_file}"
if ! grep -qxF '# Hermes managed operator PATH.' "${profile_file}"; then
printf '%s\n' \
'' \
'# Hermes managed operator PATH.' \
'case ":${PATH}:" in' \
' *":/opt/data/tools/bin:"*) ;;' \
' *) PATH="/opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin:/opt/hermes/bin:${PATH}" ;;' \
'esac' \
'export PATH' >> "${profile_file}"
fi
chmod 0644 "${profile_file}"
# Existing owner data is already written as uid/gid 10000. A
# recursive chown made every routine rollout walk the full 20Gi
# workspace while the dashboard had no endpoint. Own only the
@ -208,6 +221,7 @@ spec:
/opt/data/SOUL.md \
/opt/data/workspace/AGENTS.md \
/opt/data/workspace/START-HERE.md \
"${profile_file}" \
"${env_file}"
securityContext:
allowPrivilegeEscalation: false

View File

@ -4,7 +4,7 @@ kind: Kustomization
namespace: hermes
images:
- name: registry.bstein.dev/bstein/hermes-agent
digest: sha256:611c1642381d2a82ff03b55dda58bcab9a2e723c495dc298d1e9f938e94ed8d4
digest: sha256:de9463fab178916ff9c880af2f8dae7ffc77585e3f7b73e15f5369454ce0f3dd
resources:
- namespace.yaml
- vault-serviceaccount.yaml

View File

@ -159,6 +159,7 @@ EVENT_CALLBACK_SIGNATURE_BEFORE = ''' def _make_run_event_callback(self, run_
EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
_RUN_ACTIVITY_FILES = 256
_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0
def _record_run_activity(
self,
@ -189,6 +190,34 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
label = labels.get(event_type)
if not session_id or not label:
return
now = time.time()
if event_type in {
"_thinking",
"reasoning.available",
"subagent.progress",
"subagent_progress",
"subagent.text",
"subagent.thinking",
}:
# Streaming providers may emit hundreds of token-level progress
# callbacks. They carry no displayable detail here; keep one live
# heartbeat per session interval while retaining every persisted
# assistant message, tool call, and tool result in the session DB.
heartbeats = getattr(self, "_run_activity_heartbeats", None)
if heartbeats is None:
heartbeats = {}
self._run_activity_heartbeats = heartbeats
last_heartbeat = float(heartbeats.get(session_id, 0.0))
if now - last_heartbeat < self._RUN_ACTIVITY_HEARTBEAT_SECONDS:
return
heartbeats[session_id] = now
if len(heartbeats) > self._RUN_ACTIVITY_FILES * 2:
cutoff = now - 3_600.0
self._run_activity_heartbeats = {
key: value
for key, value in heartbeats.items()
if value >= cutoff
}
# Tool names are bounded identifiers. All previews are arbitrary model
# or tool text and can contain prompts, arguments, paths, or credentials,
# so the activity projection must never persist them.
@ -203,7 +232,7 @@ EVENT_CALLBACK_SIGNATURE_AFTER = ''' _RUN_ACTIVITY_BYTES = 512_000
entry = {
"role": "assistant",
"content": f"Activity · {content}",
"timestamp": time.time(),
"timestamp": now,
"activity_event": event_type,
}
root = Path(
@ -304,6 +333,25 @@ EVENT_CALLBACK_CALL_AFTER = ''' event_cb = self._make_run_event_callback(
self._record_run_activity(session_id, "run.started")
'''
RUN_SWEEP_BEFORE = ''' self._run_streams.pop(run_id, None)
self._run_streams_created.pop(run_id, None)
self._active_run_agents.pop(run_id, None)
self._active_run_tasks.pop(run_id, None)
self._run_approval_sessions.pop(run_id, None)
'''
RUN_SWEEP_AFTER = ''' self._run_streams.pop(run_id, None)
self._run_streams_created.pop(run_id, None)
# Stream retention and run lifetime are separate. A long run
# can legitimately outlive its unconsumed SSE queue; keep the
# control handles so /stop and approval resolution still work.
terminal_status = self._run_statuses.get(run_id, {}).get("status")
if terminal_status in {"completed", "failed", "cancelled"}:
self._active_run_agents.pop(run_id, None)
self._active_run_tasks.pop(run_id, None)
self._run_approval_sessions.pop(run_id, None)
'''
def patch(source: Path, destination: Path) -> None:
"""Apply the narrow session-lineage extension and fail on upstream drift."""
@ -319,6 +367,7 @@ def patch(source: Path, destination: Path) -> None:
(EVENT_CALLBACK_BODY_BEFORE, "event callback body"),
(EVENT_CALLBACK_END_BEFORE, "event callback end"),
(EVENT_CALLBACK_CALL_BEFORE, "event callback call"),
(RUN_SWEEP_BEFORE, "run stream sweep"),
):
if marker not in content:
raise RuntimeError(f"Hermes API {message} patch context changed")
@ -333,6 +382,7 @@ def patch(source: Path, destination: Path) -> None:
)
content = content.replace(EVENT_CALLBACK_BODY_BEFORE, EVENT_CALLBACK_BODY_AFTER, 1)
content = content.replace(EVENT_CALLBACK_END_BEFORE, EVENT_CALLBACK_END_AFTER, 1)
content = content.replace(RUN_SWEEP_BEFORE, RUN_SWEEP_AFTER, 1)
destination.write_text(
content.replace(EVENT_CALLBACK_CALL_BEFORE, EVENT_CALLBACK_CALL_AFTER, 1),
encoding="utf-8",

View File

@ -52,7 +52,11 @@ HELPER_REPLACEMENT = '''def _run_activity_messages(session_id: str) -> List[Dict
@app.get("/api/sessions/{session_id}/messages")
async def get_session_messages(session_id: str, profile: Optional[str] = None):
async def get_session_messages(
session_id: str,
profile: Optional[str] = None,
limit: Optional[int] = None,
):
'''
MESSAGES_BEFORE = ''' messages = db.get_messages(sid)
@ -64,7 +68,126 @@ MESSAGES_AFTER = ''' messages = [
*_run_activity_messages(sid),
]
messages.sort(key=lambda item: float(item.get("timestamp") or 0.0))
return {"session_id": sid, "messages": messages}
total_messages = len(messages)
if limit is not None:
messages = messages[-max(1, min(int(limit), 10_000)) :]
return {
"session_id": sid,
"messages": messages,
"total_messages": total_messages,
}
'''
LATEST_ROWS_BEFORE = ''' "SELECT id, parent_session_id, started_at FROM sessions"
).fetchall()
for row in raw_rows:
rows.append({
"id": row_get(row, "id", 0),
"parent_session_id": row_get(row, "parent_session_id", 1),
"started_at": row_get(row, "started_at", 2),
})
'''
LATEST_ROWS_AFTER = ''' "SELECT id, parent_session_id, started_at, ended_at FROM sessions"
).fetchall()
for row in raw_rows:
rows.append({
"id": row_get(row, "id", 0),
"parent_session_id": row_get(row, "parent_session_id", 1),
"started_at": row_get(row, "started_at", 2),
"ended_at": row_get(row, "ended_at", 3),
})
'''
LATEST_SELECTION_BEFORE = ''' children = {}
for row in rows:
rid = row.get("id")
parent = row.get("parent_session_id")
if rid and parent:
children.setdefault(parent, []).append(row)
def started(row):
try:
return float(row.get("started_at") or 0)
except Exception:
return 0.0
current = sid
path = [sid]
seen = {sid}
while children.get(current):
candidates = [r for r in children[current] if r.get("id") not in seen]
if not candidates:
break
candidates.sort(key=started, reverse=True)
current = candidates[0]["id"]
path.append(current)
seen.add(current)
return current, path
'''
LATEST_SELECTION_AFTER = ''' children = {}
rows_by_id = {}
for row in rows:
rid = row.get("id")
parent = row.get("parent_session_id")
if rid:
rows_by_id[rid] = row
if rid and parent:
children.setdefault(parent, []).append(row)
def started(row):
try:
return float(row.get("started_at") or 0)
except Exception:
return 0.0
# Old dashboard versions rewrote the URL to a child without retaining the
# root. Recover the oldest available ancestor so those existing live links
# can also follow a resumed parent or a newer delegated sibling.
anchor = sid
ancestor_seen = {anchor}
while True:
parent = (rows_by_id.get(anchor) or {}).get("parent_session_id")
if not parent or parent in ancestor_seen:
break
anchor = parent
ancestor_seen.add(anchor)
sid = anchor
root = db.get_session(sid) or {"id": sid}
descendants = [(root, [sid])]
stack = [(sid, [sid])]
seen = {sid}
while stack:
parent, parent_path = stack.pop()
candidates = sorted(children.get(parent, []), key=started, reverse=True)
for row in candidates:
child = row.get("id")
if not child or child in seen:
continue
seen.add(child)
child_path = [*parent_path, child]
descendants.append((row, child_path))
stack.append((child, child_path))
# A durable parent can resume after a delegated reviewer exits. Prefer the
# newest still-open member of the lineage instead of stranding the browser
# on the most recently created (but already finished) child.
active = [item for item in descendants if item[0].get("ended_at") is None]
if active:
row, path = max(active, key=lambda item: (started(item[0]), len(item[1])))
return row.get("id") or sid, path
leaves = [
item for item in descendants
if not any(child.get("id") in seen for child in children.get(item[0].get("id"), []))
]
row, path = max(leaves or descendants, key=lambda item: started(item[0]))
return row.get("id") or sid, path
'''
@ -75,7 +198,13 @@ def patch(source: Path, destination: Path) -> None:
raise RuntimeError("Hermes dashboard activity helper context changed")
if MESSAGES_BEFORE not in content:
raise RuntimeError("Hermes dashboard messages context changed")
if LATEST_ROWS_BEFORE not in content:
raise RuntimeError("Hermes dashboard lineage row context changed")
if LATEST_SELECTION_BEFORE not in content:
raise RuntimeError("Hermes dashboard lineage selection context changed")
content = content.replace(HELPER_MARKER, HELPER_REPLACEMENT, 1)
content = content.replace(LATEST_ROWS_BEFORE, LATEST_ROWS_AFTER, 1)
content = content.replace(LATEST_SELECTION_BEFORE, LATEST_SELECTION_AFTER, 1)
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(
content.replace(MESSAGES_BEFORE, MESSAGES_AFTER, 1),

View File

@ -80,6 +80,9 @@ def test_agent_config_keeps_delegated_reviewers_from_owning_task_lifecycle():
assert "Only the foreground durable worker owns its Kanban task lifecycle" in instructions
assert "must never complete, block, unblock, reclaim" in instructions
assert "task's final structured result itself" in instructions
assert "Atlas organization has private visibility" in instructions
assert "private or Gitea-internal, never public" in instructions
assert "already supplied through `GIT_ASKPASS`" in instructions
def test_sandbox_shares_only_the_tenant_workspace_without_credentials():
@ -174,6 +177,9 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
assert "SessionActivityPanel" in dockerfile
assert "include_children=${includeChildren}" in dockerfile
assert "_dashboard_session_is_active" in dockerfile
assert 'searchParams.get("lineage_root") || resumeParam' in dockerfile
assert "followActiveLineage" in dockerfile
assert 'next.delete("lineage_root")' in dockerfile
activity = (
ROOT / "dockerfiles" / "hermes-session-activity-panel.tsx"
).read_text()
@ -184,6 +190,15 @@ def test_gateway_image_honors_ui_model_and_caps_reasoning():
assert "aria-busy={running && messages.length === 0}" in activity
assert 'detail.source !== "api_server"' not in activity
assert "Poll-backed transcript; terminal rendering is not required." in activity
assert "DEFAULT_VISIBLE_MESSAGES = 250" in activity
assert "current + DEFAULT_VISIBLE_MESSAGES" in activity
assert 'Show up to{" "}' in activity
assert "Math.min(" in activity
assert "visibleMessages.map" in activity
assert "response.total_messages" in activity
assert "visibleLimit)," in activity
assert "function displayText(value: unknown)" in activity
assert "open && (" in activity
assert "Open Telegram setup" in dockerfile
@ -1071,6 +1086,7 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
+ "tool start body\n"
+ module.EVENT_CALLBACK_END_BEFORE
+ module.EVENT_CALLBACK_CALL_BEFORE
+ module.RUN_SWEEP_BEFORE
+ "suffix\n",
encoding="utf-8",
)
@ -1099,6 +1115,14 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path):
assert 'self._record_run_activity(session_id, "run.started")' in patched
assert 'detail = tool_name if event_type in {' in patched
assert 'if event_type == "subagent.tool"' in patched
assert "_RUN_ACTIVITY_HEARTBEAT_SECONDS = 15.0" in patched
assert 'heartbeats.get(session_id, 0.0)' in patched
assert '"subagent.thinking",' in patched
assert "Stream retention and run lifetime are separate" in patched
assert 'terminal_status in {"completed", "failed", "cancelled"}' in patched
assert patched.index("terminal_status = self._run_statuses") < patched.index(
"self._active_run_tasks.pop(run_id, None)"
)
def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
@ -1112,6 +1136,8 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
destination = tmp_path / "patched.py"
source.write_text(
"prefix\n"
+ module.LATEST_ROWS_BEFORE
+ module.LATEST_SELECTION_BEFORE
+ module.HELPER_MARKER
+ " db = object()\n"
+ module.MESSAGES_BEFORE
@ -1128,6 +1154,114 @@ def test_web_session_activity_patch_projects_bounded_events(tmp_path: Path):
assert "entries[-1_000:]" in patched
assert "*_run_activity_messages(sid)" in patched
assert "messages.sort(" in patched
assert "limit: Optional[int] = None" in patched
assert "total_messages = len(messages)" in patched
assert "min(int(limit), 10_000)" in patched
assert '"total_messages": total_messages' in patched
assert "SELECT id, parent_session_id, started_at, ended_at" in patched
assert "newest still-open member of the lineage" in patched
assert "oldest available ancestor" in patched
assert 'item[0].get("ended_at") is None' in patched
def test_web_session_lineage_returns_to_resumed_parent():
"""An ended reviewer must not strand the live view away from its parent."""
module_path = HERMES / "scripts" / "patch_web_session_activity.py"
spec = importlib.util.spec_from_file_location("patch_web_lineage", module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
namespace: dict[str, object] = {}
exec(
"def select_active(sid, db, rows):\n" + module.LATEST_SELECTION_AFTER,
namespace,
)
class FakeDB:
root = {
"id": "root",
"started_at": 1.0,
"ended_at": None,
}
def get_session(self, _session_id):
return self.root
rows = [
{
"id": "root",
"parent_session_id": None,
"started_at": 1.0,
"ended_at": None,
},
{
"id": "review-1",
"parent_session_id": "root",
"started_at": 2.0,
"ended_at": 3.0,
},
{
"id": "review-2",
"parent_session_id": "root",
"started_at": 4.0,
"ended_at": None,
},
]
select_active = namespace["select_active"]
assert select_active("root", FakeDB(), rows) == (
"review-2",
["root", "review-2"],
)
assert select_active("review-1", FakeDB(), rows) == (
"review-2",
["root", "review-2"],
)
rows[1]["ended_at"] = 5.0
rows[2]["ended_at"] = 5.0
assert select_active("root", FakeDB(), rows) == ("root", ["root"])
def test_api_activity_patch_coalesces_streaming_heartbeats(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
):
"""Token callbacks stay bounded while every tool transition is retained."""
module_path = HERMES / "scripts" / "patch_api_server_sessions.py"
spec = importlib.util.spec_from_file_location("patch_api_activity", module_path)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
activity_body = module.EVENT_CALLBACK_SIGNATURE_AFTER.split(
" def _make_run_event_callback(", 1
)[0]
namespace: dict[str, object] = {}
exec(
"import hashlib, json, logging, os, time\n"
"from pathlib import Path\n"
"logger = logging.getLogger(__name__)\n"
"def redact_sensitive_text(value): return value\n"
"class ActivityRecorder:\n"
+ activity_body,
namespace,
)
recorder = namespace["ActivityRecorder"]()
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
recorder._record_run_activity("session", "_thinking")
recorder._record_run_activity("session", "reasoning.available")
recorder._record_run_activity("session", "subagent.thinking")
recorder._record_run_activity("session", "tool.started", tool_name="terminal")
recorder._record_run_activity("session", "tool.completed", tool_name="terminal")
journals = list((tmp_path / "run-activity").glob("*.jsonl"))
assert len(journals) == 1
entries = [json.loads(line) for line in journals[0].read_text().splitlines()]
assert [entry["activity_event"] for entry in entries] == [
"_thinking",
"tool.started",
"tool.completed",
]
def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path):

View File

@ -1204,6 +1204,15 @@ def test_owner_agent_installs_the_pinned_operator_toolchain():
)
assert "/bin/sh /opt/coordinator/install_agent_tools.sh" in installer["command"][2]
assert any(mount["name"] == "coordinator" for mount in installer["volumeMounts"])
init_config = next(
item
for item in deployment["spec"]["template"]["spec"]["initContainers"]
if item["name"] == "init-config"
)
init_command = init_config["command"][2]
assert "# Hermes managed operator PATH." in init_command
assert "/opt/coordinator:/opt/data/tools/bin:/opt/hermes/.venv/bin" in init_command
assert 'chmod 0644 "${profile_file}"' in init_command
def test_owner_agent_uses_only_the_canonical_hostname():