Skip to content

fix: preserve clarify drafts on timeout#1216

Closed
sixianli wants to merge 3 commits intonesquena:masterfrom
sixianli:fix/clarify-timeout-draft
Closed

fix: preserve clarify drafts on timeout#1216
sixianli wants to merge 3 commits intonesquena:masterfrom
sixianli:fix/clarify-timeout-draft

Conversation

@sixianli
Copy link
Copy Markdown

Thinking Path

  • Hermes WebUI exposes Hermes Agent clarify prompts in the browser.
  • Some clarify prompts ask for open-ended idea descriptions, which can take longer than the default timeout while the user is still typing.
  • Before this change, the WebUI did not show the timeout deadline, and when the pending clarify prompt disappeared, the typed draft was cleared from the clarify input.
  • This made it easy for users to lose meaningful long-form input without understanding why.
  • This PR keeps the existing timeout behavior, but makes the timeout visible and preserves unsent draft text when the prompt expires or closes.

What Changed

  • Added timeout metadata to pending clarify payloads:
    • requested_at
    • timeout_seconds
    • expires_at
  • Added a countdown indicator to the clarify card so users can see how much time remains.
  • Added urgent countdown styling for the final seconds before expiration.
  • Preserved unsent clarify draft text by moving it into the main composer when the clarify prompt expires or closes before submission.
  • Avoided preserving already-submitted clarify text.
  • Added regression coverage for clarify timeout metadata, countdown rendering, and draft preservation behavior.

Why It Matters

Open-ended clarify prompts are often used for higher-value input, such as describing an idea or clarifying intent. Losing that draft after a hidden timeout is frustrating and can interrupt the user's thought process.

With this change, users can see the timeout risk before it happens, and their typed draft remains available in the main composer if the clarify prompt expires.

Screenshots

Before: clarify prompt had no visible timeout.

After: clarify prompt shows a visible countdown.

1 2

After timeout/close: unsent clarify draft is preserved in the main composer.

3 4

Verification

  • uv run --with pytest pytest tests/test_clarify_unblock.py tests/test_sprint30.py -q
  • uv run --with ruff ruff check api/clarify.py tests/test_clarify_unblock.py tests/test_sprint30.py
  • git --no-pager diff --check
  • Manual browser check on a Linux-hosted Hermes WebUI instance:
    • injected a test clarify prompt
    • verified countdown display
    • typed an unsent draft
    • resolved/closed the prompt
    • verified the draft moved into the main composer

Risks / Follow-ups

  • Full pytest tests/ -v --timeout=60 has not been run yet.
  • This PR keeps the existing 120-second clarify timeout; it does not make the timeout configurable.
  • Clarify responses still use a single-line input. A future improvement could use a textarea for open-ended prompts.

Model Used

Provider: OpenAI
Model: GPT-5 via Codex desktop
Notable tool use: local shell, SSH deployment to a Linux test host, Git, targeted pytest/ruff verification

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Thanks for the detailed PR, @sixianli! This addresses a real UX pain point — losing a long typed draft after a silent timeout is frustrating, and making the deadline visible + preserving the draft on expiry is the right fix.

Overall assessment: well-scoped and thoughtfully implemented.

A few things to verify before merge:

Timeout metadata source
The PR adds requested_at, timeout_seconds, and expires_at to pending clarify payloads. Please confirm these fields are populated server-side (in api/clarify.py or equivalent) and not just synthesized client-side — if the client invents the expiry, the countdown could drift from the actual backend timeout.

Draft preservation — edge cases

  • If the user has content in the main composer before the clarify prompt expires, the moved draft should be appended (or shown in a conflict state), not silently overwrite what was already there. Please confirm the implementation handles this.
  • Draft text should not be preserved for prompts the user explicitly dismissed vs. those that timed out silently — the PR description says "avoids preserving already-submitted clarify text," but it's worth confirming that manual close/cancel also does not trigger the preservation path.

Full test suite
The PR notes that pytest tests/ -v --timeout=60 has not been run yet. Please run the full suite before re-requesting review — the regression coverage looks solid but we want to make sure nothing else is affected by the clarify metadata changes.

Minor nib
Urgent countdown styling in the final seconds is a nice touch — just make sure the styling is accessible (not color-only) for users with color vision differences.

Once the full test suite passes and the edge cases above are confirmed, this looks close to ready. Happy to do a follow-up review pass!

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Thanks for the follow-up commits, @sixianli! Two new additions since the last review:

  • fix: handle clarify review edge cases — good to see edge case coverage being added.
  • fix: keep clarify countdown steady — this addresses any jitter or inconsistency in the countdown display, which matters for the UX since users are watching it.

The PR is looking solid across api/clarify.py, static/messages.js, static/index.html, static/style.css, and the two test files. The scope is well-contained.

For the countdown steady fix specifically: is the issue timer drift (e.g. setInterval vs wall-clock drift), or a rendering issue where the countdown was being re-initialized on re-renders? Either way, good catch before merge.

The PR is shaping up well — appreciate the iterative polish!

@sixianli
Copy link
Copy Markdown
Author

I pushed two follow-up commits after the review feedback:

  • 70aea31 - handle clarify review edge cases
  • 1611bc3 - keep clarify countdown steady

Timeout metadata source

Confirmed. The timeout metadata is populated server-side in api/clarify.py, not synthesized by the client.

submit_pending() calls _with_timeout_metadata() before the clarify request is queued or sent to the UI callback. That helper sets:

  • requested_at
  • timeout_seconds
  • expires_at

The frontend only consumes those fields. It uses pending.expires_at first, with pending.requested_at + pending.timeout_seconds as a fallback when both are present.

Draft preservation edge cases

Confirmed and covered with follow-up tests.

If the main composer already contains text, the clarify draft is appended with a blank-line separator instead of replacing the existing composer value.

Explicit user actions do not trigger draft preservation:

  • submitted clarify text uses the sent reason
  • explicit stream cancellation now uses the cancelled reason
  • _stashClarifyDraft() ignores both of those reasons

Draft preservation is limited to timeout / terminal closure paths, not explicit submit or cancel paths.

Countdown accessibility

The urgent countdown state now has a non-color visual cue as well as the error color. I added a regression test to make sure the urgent styling is not color-only.

Additional countdown fix found during manual testing

While testing the deployed PR build, we found one more countdown issue. The pending clarify poll refreshes the same prompt every ~1.5s, and the previous implementation restarted the countdown interval on each refresh. That could make the visible countdown jump by two numbers within one second.

Fixed in 1611bc3: _startClarifyCountdown() now keeps the existing interval running when the pending clarify payload has the same expires_at, so polling no longer resets the timer. Added regression coverage for this case.

Verification

Targeted regression checks:

  • uv run --with pytest pytest tests/test_clarify_unblock.py tests/test_sprint30.py -q -> 88 passed
  • uv run --with ruff ruff check api/clarify.py tests/test_clarify_unblock.py tests/test_sprint30.py -> passed
  • git diff --check -> passed

Full suite:

I ran the full suite in a detached /tmp worktree with isolated HOME / Hermes state:

  • pytest tests/ -v --timeout=60
  • result: 1 failed, 2791 passed, 54 skipped, 1 warning, 8 subtests passed

The remaining failure is:

  • tests/test_sprint31.py::TestProfileCreateAPIWithEndpoint::test_api_route_writes_base_url_to_config

I reproduced that same failure on upstream/master with the sprint31 test class, so it appears unrelated to this PR. I am not claiming the full suite is clean locally, but the remaining failure does not appear to be introduced by these clarify changes.

Manual verification:

  • Deployed the updated PR branch to a test WebUI instance.
  • Confirmed the clarify countdown now decrements steadily once per second.
  • Confirmed the draft preservation behavior works as expected after timeout.

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Thanks for the detailed follow-up, @sixianli! The two additional commits address the remaining review feedback thoroughly.

Timeout metadata source (confirmed): Good to have the explicit confirmation that _with_timeout_metadata() is called server-side before the request is queued, and the frontend only consumes expires_at / requested_at + timeout_seconds. The fallback chain is solid.

Draft preservation edge cases (confirmed):

  • Append-with-separator behavior when the composer already has text is the right approach — avoids silently overwriting existing content.
  • Restricting draft stash to timeout/terminal closure paths (not sent/cancelled) is correct. The elif guard on reasons prevents accidentally stashing drafts from explicit user actions.

Countdown accessibility (confirmed): Non-color visual cue added alongside the error color for the urgent state, with regression coverage. Good.

Countdown stability fix (1611bc3): The _startClarifyCountdown() same-expires_at guard is a nice catch — preventing the interval from resetting on each ~1.5s poll was a real UX issue (visible jump in the countdown). The fix is correct: keep the existing interval running if the payload hasn't changed.

88 passed on the targeted test modules and the full suite at 1 failed / 2791 passed. The remaining failure (test_api_route_writes_base_url_to_config) reproducible on upstream/master confirms it's pre-existing and unrelated.

This looks ready for maintainer review and merge. Great thorough work on the follow-ups! 🎉

@nesquena-hermes
Copy link
Copy Markdown
Collaborator

Merged in v0.50.237 via #1243. Thank you @sixianli! 🎉

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants