Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function useSessionTileDelegate({
requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
omit_messages: true,
...(profile ? { profile } : {})
})
])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1402,7 +1402,7 @@ describe('usePromptActions redirectPrompt', () => {
expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true)
expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect'])
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID, text: 'reconnect nudge' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', omit_messages: true })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'reconnect nudge' })
expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID)
})
Expand Down Expand Up @@ -1832,7 +1832,7 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', omit_messages: true })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})

Expand Down Expand Up @@ -1876,7 +1876,12 @@ describe('usePromptActions sleep/wake session recovery', () => {
)

expect(await handle!.submitText('message after wake')).toBe(true)
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
expect(calls[1]?.params).toEqual({
session_id: STORED_SESSION_ID,
source: 'desktop',
omit_messages: true,
profile: 'work'
})

setSessions(() => [])
})
Expand Down Expand Up @@ -1923,7 +1928,12 @@ describe('usePromptActions sleep/wake session recovery', () => {
)

expect(await handle!.submitText('message after wake')).toBe(true)
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop', profile: 'work' })
expect(calls[1]?.params).toEqual({
session_id: STORED_SESSION_ID,
source: 'desktop',
omit_messages: true,
profile: 'work'
})

vi.mocked(getSession).mockReset()
setSessions(() => [])
Expand Down Expand Up @@ -1976,7 +1986,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
session_id: 'rt-background-stale',
text: 'queued background message after wake'
})
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({
session_id: STORED_SESSION_ID,
source: 'desktop',
omit_messages: true
})
expect(calls[2]?.params).toEqual({
session_id: RECOVERED_SESSION_ID,
text: 'queued background message after wake'
Expand Down Expand Up @@ -2023,7 +2037,11 @@ describe('usePromptActions sleep/wake session recovery', () => {

expect(calls.map(c => c.method)).toEqual(['session.interrupt', 'session.resume', 'session.interrupt'])
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({
session_id: STORED_SESSION_ID,
source: 'desktop',
omit_messages: true
})
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID })
})

Expand Down Expand Up @@ -2155,7 +2173,11 @@ describe('usePromptActions sleep/wake session recovery', () => {

expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[1]?.params).toEqual({
session_id: STORED_SESSION_ID,
source: 'desktop',
omit_messages: true
})
expect(calls[2]?.params).toEqual({
session_id: RECOVERED_SESSION_ID,
text: 'message during starved loop'
Expand Down Expand Up @@ -2198,7 +2220,11 @@ describe('usePromptActions sleep/wake session recovery', () => {
expect(ok).toBe(true)
expect(createBackendSessionForSend).not.toHaveBeenCalled()
expect(calls.map(c => c.method)).toEqual(['session.resume', 'prompt.submit'])
expect(calls[0]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[0]?.params).toEqual({
session_id: STORED_SESSION_ID,
source: 'desktop',
omit_messages: true
})
expect(calls[1]?.params).toMatchObject({ session_id: RECOVERED_SESSION_ID })
})

Expand Down Expand Up @@ -2508,7 +2534,8 @@ describe('usePromptActions submit session-context isolation (#54527)', () => {
expect(calls.some(c => c.method === 'prompt.submit')).toBe(false)
expect(calls.find(c => c.method === 'session.resume')?.params).toEqual({
session_id: STORED_SESSION_A,
source: 'desktop'
source: 'desktop',
omit_messages: true
})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ export function usePromptActions({
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop',
omit_messages: true,
...(resumeProfile ? { profile: resumeProfile } : {})
})

Expand Down Expand Up @@ -709,6 +710,7 @@ export function usePromptActions({
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop',
omit_messages: true,
...(resumeProfile ? { profile: resumeProfile } : {})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: targetStoredSessionId,
source: 'desktop',
omit_messages: true,
...(resumeProfile ? { profile: resumeProfile } : {})
})

Expand Down Expand Up @@ -539,6 +540,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: recoverStoredSessionId,
source: 'desktop',
omit_messages: true,
...(resumeProfile ? { profile: resumeProfile } : {})
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -889,7 +889,7 @@ describe('resumeSession failure recovery', () => {

expect(resumeParams).not.toHaveProperty('lazy')
expect(resumeParams).not.toHaveProperty('eager_build')
expect(resumeParams).toMatchObject({ source: 'desktop' })
expect(resumeParams).toMatchObject({ source: 'desktop', omit_messages: true })
})

it('arms the failure latch when resume succeeds with an empty transcript for a non-empty stored session', async () => {
Expand Down Expand Up @@ -1238,6 +1238,10 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(methods).toContain('session.activate')
expect(methods).not.toContain('session.resume')
expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined)
expect(requestGateway).toHaveBeenCalledWith(
'session.activate',
expect.objectContaining({ omit_messages: true, session_id: 'rt-A' })
)
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -680,7 +680,8 @@ export function useSessionActions({
try {
activated = await requestGateway<SessionResumeResponse>('session.activate', {
session_id: cachedRuntimeId,
cols: 96
cols: 96,
omit_messages: true
})
} catch (error) {
// Compatibility for older backends. Modern backends require
Expand Down Expand Up @@ -842,12 +843,14 @@ export function useSessionActions({
session_id: storedSessionId,
cols: 96,
source: 'desktop',
// REST is the transcript authority for Desktop. Avoid duplicating a
// potentially huge compression lineage in the WebSocket response.
// Watch windows attach lazily (live mirror). Every other cold resume
// gets the gateway's default deferred build: the RPC returns the
// transcript immediately instead of blocking the switch on _make_agent
// (MCP discovery / prompt build), and the agent pre-warms in the
// background while the prefetch above paints the transcript.
...(watchWindow ? { lazy: true } : {}),
...(watchWindow ? { lazy: true } : { omit_messages: true }),
...(sessionProfile ? { profile: sessionProfile } : {})
})

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -456,6 +456,7 @@ export interface SessionResumeResponse {
info?: SessionRuntimeInfo
message_count: number
messages: SessionMessage[]
messages_omitted?: boolean
resumed: string
running?: boolean
session_id: string
Expand Down
75 changes: 75 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12055,6 +12055,81 @@ async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit:
return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit)


_MAX_CRON_OUTPUT_CHARS = 200_000


def _cron_output_dir_for_job(home: Path, job_id: str) -> Path:
text = str(job_id or "").strip()
if not text or text in {".", ".."} or "/" in text or "\\" in text:
raise HTTPException(status_code=400, detail="Invalid cron job id")

base = (home / "cron" / "output").resolve()
target = (base / text).resolve()
if target != base and base not in target.parents:
raise HTTPException(status_code=400, detail="Invalid cron output path")
return target


def _list_cron_job_outputs_sync(
job_id: str,
profile: Optional[str] = None,
limit: int = 5,
):
"""Recent markdown outputs for a cron job, newest first."""
selected = profile or _find_cron_job_profile(job_id)
if not selected:
raise HTTPException(status_code=404, detail="Job not found")

job = _call_cron_for_profile(selected, "get_job", job_id)
if not job:
raise HTTPException(status_code=404, detail="Job not found")
canonical = str(job.get("id") or job_id)

try:
limit_n = max(1, min(int(limit), 25))
except (TypeError, ValueError):
limit_n = 5

_profile_name, home = _cron_profile_home(selected)
output_dir = _cron_output_dir_for_job(home, canonical)
if not output_dir.exists():
return {"outputs": [], "limit": limit_n}

files = sorted(
[p for p in output_dir.glob("*.md") if p.is_file()],
key=lambda p: (p.stat().st_mtime, p.name),
reverse=True,
)[:limit_n]

outputs: List[Dict[str, Any]] = []
for path in files:
stat_result = path.stat()
text = path.read_text(encoding="utf-8", errors="replace")
truncated = len(text) > _MAX_CRON_OUTPUT_CHARS
if truncated:
text = text[:_MAX_CRON_OUTPUT_CHARS]
outputs.append(
{
"id": path.stem,
"filename": path.name,
"created_at": datetime.fromtimestamp(
stat_result.st_mtime,
tz=timezone.utc,
).isoformat(),
"size": stat_result.st_size,
"content": text,
"truncated": truncated,
"profile": selected,
}
)
return {"outputs": outputs, "limit": limit_n}


@app.get("/api/cron/jobs/{job_id}/outputs")
async def list_cron_job_outputs(job_id: str, profile: Optional[str] = None, limit: int = 5):
return await _run_cron_dashboard_io(_list_cron_job_outputs_sync, job_id, profile, limit)


def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None):
try:
profile_name, profile_home = _cron_profile_home(profile)
Expand Down
49 changes: 49 additions & 0 deletions tests/hermes_cli/test_web_server_cron_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from concurrent.futures import ThreadPoolExecutor
import json
import os
from queue import Empty, SimpleQueue
import threading

Expand Down Expand Up @@ -249,6 +250,54 @@ async def test_list_cron_jobs_specific_profile_filters_results(isolated_profiles
assert jobs[0]["profile"] == "worker_alpha"


@pytest.mark.asyncio
async def test_list_cron_job_outputs_reads_named_profile_newest_first(
isolated_profiles,
):
from hermes_cli import web_server

job = web_server._call_cron_for_profile(
"worker_alpha",
"create_job",
prompt="write a saved report",
schedule="every 1h",
name="saved-output-job",
)
output_dir = isolated_profiles["worker_alpha"] / "cron" / "output" / job["id"]
output_dir.mkdir(parents=True)
older = output_dir / "older.md"
newer = output_dir / "newer.md"
older.write_text("old report", encoding="utf-8")
newer.write_text("new report", encoding="utf-8")
os.utime(older, (100, 100))
os.utime(newer, (200, 200))

result = await web_server.list_cron_job_outputs(job["id"], limit=1)

assert result["limit"] == 1
assert result["outputs"] == [
{
"id": "newer",
"filename": "newer.md",
"created_at": "1970-01-01T00:03:20+00:00",
"size": len("new report"),
"content": "new report",
"truncated": False,
"profile": "worker_alpha",
}
]


@pytest.mark.parametrize("job_id", ["", ".", "..", "../escape", "nested/job", r"nested\\job"])
def test_cron_output_dir_rejects_unsafe_job_ids(tmp_path, job_id):
from hermes_cli import web_server

with pytest.raises(HTTPException) as exc:
web_server._cron_output_dir_for_job(tmp_path, job_id)

assert exc.value.status_code == 400


@pytest.mark.asyncio
async def test_create_cron_job_normalizes_representative_core_fields(
isolated_profiles, tmp_path
Expand Down
Loading