Skip to content

fix(gateway): show descriptive chat titles instead of hex hash IDs (#2237)#2700

Open
zmanian wants to merge 11 commits intomainfrom
fix/2237-chat-title-generation-v2
Open

fix(gateway): show descriptive chat titles instead of hex hash IDs (#2237)#2700
zmanian wants to merge 11 commits intomainfrom
fix/2237-chat-title-generation-v2

Conversation

@zmanian
Copy link
Copy Markdown
Collaborator

@zmanian zmanian commented Apr 20, 2026

Replaces #2348 — recreated on fresh staging after gateway and web handler refactors (#2683, #2599).

Summary

Three-layer fix for new conversations displaying truncated UUIDs in the web sidebar:

  • Persistence layer — Store conversation title in metadata on first user message (both v1 agent and v2 engine paths) via shared set_title_if_missing() helper in src/db/mod.rs. Empty/whitespace-only input is skipped so image-only messages don't permanently block title setting.
  • SQL derivation — Both PostgreSQL and libSQL title derivation now check metadata.title as a fallback between the message-subquery title and routine_name.
  • FrontendthreadTitle() (ported to js/core/history.js post-refactor(gateway): split monolithic style.css and app.js into per-surface modules #2683) shows localized "Untitled chat" instead of hex hash substring. In-memory fallback derives title from first turn's input with whitespace normalization (addresses review feedback about multi-line input).

Files changed

  • src/channels/web/features/chat/mod.rs — in-memory title derivation (ported from handlers/chat.rs post-Epic: Enforce gateway feature boundaries, crate guardrails, and crate-owned E2E #2599)
  • src/agent/thread_ops.rs, src/bridge/router.rs — both call shared set_title_if_missing()
  • src/db/mod.rsset_title_if_missing() helper
  • src/db/libsql/conversations.rs, src/history/store.rs — metadata.title fallback in SQL title derivation
  • crates/ironclaw_gateway/static/js/core/history.jsthreadTitle() i18n
  • crates/ironclaw_gateway/static/i18n/{en,ko,zh-CN}.js — new keys

Notes

  • Addresses prior feedback from @serrrfirat: deduplicated title-setting code, empty-input guard, whitespace normalization.
  • TOCTOU concern on concurrent title-set remains (noted but deferred — benign race, worst case title reflects second message).

Test plan

  • cargo check -p ironclaw clean
  • Manual: create new thread, verify sidebar shows first-message title instead of hex ID

Closes #2237

@github-actions github-actions Bot added scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: db Database trait / abstraction size: L 200-499 changed lines risk: medium Business logic, config, or moderate-risk modules contributor: core 20+ merged PRs labels Apr 20, 2026
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f07b49c51b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/history/store.rs
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements automatic conversation title generation and improves i18n support for chat threads. It introduces a mechanism to set a title from the first user message if one is missing and updates the database logic to prioritize these titles. Feedback focuses on ensuring consistent whitespace normalization across all title derivation paths and optimizing error handling when conversation metadata is missing.

Comment thread src/db/mod.rs Outdated
Comment thread src/db/mod.rs Outdated
Comment thread src/db/libsql/conversations.rs
Comment thread src/db/libsql/conversations.rs
Comment thread src/history/store.rs
Comment thread src/history/store.rs
@ilblackdragon
Copy link
Copy Markdown
Member

Code Review

Overview

Three-layer fix so new conversations show a real title in the web sidebar instead of id.substring(0,8):

  1. Backend writeset_title_if_missing() helper persists the first user message as metadata.title on both v1 agent (thread_ops.rs) and v2 engine (bridge/router.rs) paths.
  2. SQL read — libSQL + PostgreSQL title derivation check metadata.title as a fallback between the message-subquery title and routine_name.
  3. FrontendthreadTitle() returns localized "Untitled chat" instead of hex; in-memory listing (chat_threads_handler) derives title from the first turn with whitespace normalization.

CI is green across fmt/clippy/deny/replay; only en/ko/zh-CN locales exist, so translations are complete.

Strengths

  • Clean, idiomatic Rust; shared helper (set_title_if_missing) correctly placed in src/db/mod.rs and reused across both engine paths — no duplication.
  • Empty-input guard prevents image/attachment-only messages from permanently poisoning the title slot.
  • Good regression tests on libSQL: both "metadata fallback used" and "message title wins over metadata" paths are covered.
  • Char-aware truncation (chars().take(100)) avoids multibyte splits.
  • Fire-and-forget title update doesn't block the message-persistence happy path.

Issues & Suggestions

Correctness

  • thread_ops.rs:1079-1082 — Title is now set unconditionally after the match, including when add_conversation_message returned Err (the match arm logs and returns None). That can produce a conversation with a metadata.title but no persisted user message — minor "ghost title" case. Consider moving the call inside the Ok arm, or gating it on result.is_some().
  • db/mod.rs:522-525Err(_) => return silently swallows storage errors. CLAUDE.md's style guide calls for error context (.map_err(...)); at minimum a debug!("failed to read metadata for title-set: {e}") would help debugging without bloating the code.
  • Whitespace inconsistencychat_threads_handler normalizes whitespace via split_whitespace().join(" "), but set_title_if_missing only trim()s. A multi-line first message persisted today will keep its internal newlines in metadata.title, while the in-memory path (pre-persist) would render it collapsed. Low impact but easy to align — collapse whitespace in the helper too.

Performance

  • set_title_if_missing does two DB roundtrips (read metadata, then write) on every user message of an existing conversation that already has a title. A conditional-update SQL (UPDATE ... WHERE metadata->>'title' IS NULL) would be one roundtrip and also race-free. The PR's deferred TOCTOU note would resolve as a side effect.

Test coverage

  • Good coverage for libSQL; no parallel tests for history/store.rs (PostgreSQL path) — the same metadata-title fallback logic was added there but isn't exercised. Worth a mirror test under --features integration.
  • No direct test for set_title_if_missing itself (empty-input guard, already-has-title no-op, 100-char truncation).
  • chat_threads_handler's in-memory title derivation (the multi-line/whitespace case cited in the PR body) isn't regression-tested. Per CLAUDE.md's "test through the caller" rule, this is the kind of handler-level behavior worth an integration test.

Security

  • User input is persisted verbatim (trim + 100-char cap). No control-char/newline filtering. Not a vuln since JSON encodes safely, but worth confirming the frontend renders metadata.title via textContent/I18n.t rather than as HTML.

Style / conventions

  • Follows the codebase: crate::db::set_title_if_missing (no re-export), dispatch-exempt isn't needed since the call lives in the engine/bridge path, not a UI mutation handler.
  • Doc comment on the helper explains the why (empty-input guard) — matches CLAUDE.md guidance.

Risk assessment

Low risk. Fully additive: existing sql_title still takes precedence, so renamed/derived titles are unaffected. The one behavioral change — titles appearing sooner for conversations with no message-subquery hit — is the intended fix. The "ghost title on failed persist" edge case is the only real correctness nit; the rest is polish.

Recommendation

Approve with minor nits. Worth addressing the Err ordering in thread_ops.rs and adding a PostgreSQL regression test before merge; the whitespace-normalization alignment and conditional-update optimization can be follow-ups.

zmanian and others added 5 commits April 20, 2026 21:08
…2237)

New conversations in the web sidebar were displaying truncated UUIDs
(e.g., "5638f52c") because the title fell through to the raw ID
fallback when no title was available from the database.

Three-layer fix:
- Store conversation title in metadata on first user message (both v1
  and v2 engine paths) so titles persist even if the SQL subquery has
  timing issues
- SQL title derivation now checks metadata.title as a fallback between
  the message-subquery title and routine_name (both PostgreSQL and
  libSQL backends)
- Frontend threadTitle() shows "Untitled chat" via i18n instead of
  hex hash substring; in-memory fallback derives title from first turn

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…me advisories

rand 0.8.5 unsoundness with custom logger is pinned by transitive deps
(wasmtime-wasi, libsql, rig-core, zbus, tower 0.4). Upgrade tracked
separately. Remove 4 wasmtime advisories resolved by v43 upgrade.

https://claude.ai/code/session_01MMhMuxXvAXTcFZ3EAga12k
Extract shared set_title_if_missing() helper into db module to eliminate
duplicated title-setting code between thread_ops.rs and bridge/router.rs.
Skip empty/whitespace-only input to prevent permanently blocking
title-setting on image-only or attachment-only messages.

Addresses PR #2348 review feedback (duplicated code, empty input blocking).

[skip-regression-check]

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…stls-webpki advisory ignores

The set_title_if_missing call added after the match tail expression broke
the return type (Option<Uuid> vs ()). Capture the match result in a
variable so the title-setting runs before returning. Also restore
RUSTSEC-2026-0098 and RUSTSEC-2026-0099 ignores — rustls-webpki 0.102.8
is still pinned by the libsql transitive dep.

https://claude.ai/code/session_01JRasj3ujmr1uzmfUeLbNFo
…g conversation

Addresses review feedback on PR #2700:
- Return early from set_title_if_missing when conversation record doesn't
  exist (Ok(None)) to avoid a failing metadata update attempt
- Normalize whitespace in title_text (collapse multi-space/newlines)
- Filter blank sql_title values before metadata fallback in both libSQL
  and PostgreSQL paths so image-only/empty first turns properly fall
  through to metadata.title

https://claude.ai/code/session_01GHzXcZjSjxHEeTDDHG4fQK
@zmanian zmanian force-pushed the fix/2237-chat-title-generation-v2 branch from f07b49c to 716b878 Compare April 20, 2026 21:20
claude added 2 commits April 20, 2026 22:11
…log metadata errors

- Move set_title_if_missing call inside Ok arm so failed message
  persists don't produce ghost titles (ilblackdragon review)
- Log metadata read errors at debug level instead of silently swallowing

[skip-regression-check]

https://claude.ai/code/session_01BsM2KUzdZNBeoLjk7LymZS
Copy link
Copy Markdown
Collaborator Author

zmanian commented Apr 21, 2026

Addressed @ilblackdragon's review nits in de2876b:

  1. Ghost title on failed persistset_title_if_missing now only runs when persist_user_message succeeded (result.is_some() gate in thread_ops.rs).
  2. Silent error swallowErr(_) => return in set_title_if_missing now logs at debug! level before returning.
  3. Whitespace normalization — already aligned: set_title_if_missing uses split_whitespace().join(" ") (line 521-524), matching the in-memory path.

Clippy clean, zero warnings.


Generated by Claude Code

Copy link
Copy Markdown
Collaborator

@serrrfirat serrrfirat left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-checked this against current staging. The underlying sidebar-title bug is still real, but I found a few remaining correctness gaps before merge; details inline.

Comment thread src/bridge/router.rs Outdated
Comment thread src/db/libsql/conversations.rs
Comment thread src/bridge/router.rs Outdated
…, expand tests

Three review issues on #2700:

1. Title source (router.rs + thread_ops.rs). Titles were derived from
   `effective_content`, which is the attachment-augmented payload
   produced by `augment_with_attachments` — it can include a
   synthesized `<attachments>` block or extracted OCR/pdf text. On an
   image- or attachment-only first turn this meant the sidebar title
   was the synthesized attachment text rather than user input, which
   defeats the point of skipping empty turns. Both call sites now pass
   the raw user `content` to `set_title_if_missing` while the
   conversation row body continues to store the augmented payload.
   `persist_user_message` takes a new `title_source` argument to keep
   the two distinct on the v1 path.

2. Ghost title on failed persist (router.rs). The v2 bridge dual-write
   discarded the `add_conversation_message` result with `let _`, so
   `set_title_if_missing` ran even when the first user row never
   landed — reproducing the ghost-title edge case thread_ops.rs
   already guards against. The result is now inspected and the title
   write is gated on `Ok`.

3. Test coverage (store.rs + thread_ops.rs). The libSQL regression
   tests don't pin the PG path or the caller wiring. Adds:
   - PG mirrors of `test_metadata_title_used_as_fallback` and
     `test_message_title_takes_precedence_over_metadata` behind
     `feature = "postgres"` + `#[ignore]` (integration tier).
   - A caller-level regression in `thread_ops` that drives
     `Agent::persist_user_message` against a real LibSqlBackend and
     pins all three invariants: attachment-augmented payload with
     real text uses the raw text; attachment-only first turn leaves
     the title unset; plain text still seeds correctly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added size: XL 500+ changed lines and removed size: L 200-499 changed lines labels Apr 21, 2026
@zmanian zmanian requested a review from serrrfirat April 21, 2026 12:48
Comment thread src/channels/web/features/chat/mod.rs Outdated
Comment thread src/bridge/router.rs
Comment thread src/db/mod.rs Outdated
@serrrfirat
Copy link
Copy Markdown
Collaborator

I'm not sure if this is still an issue?

…tle write atomic

Addresses three unresolved review comments on #2700:

1. In-memory threads fallback (src/channels/web/features/chat/mod.rs).
   The no-DB path in `chat_threads_handler` still derived its sidebar
   title from `turn.user_input`, which is the attachment-augmented
   payload stamped by `process_user_input`. Adds a new
   `raw_user_input: Option<String>` field to `Turn`, populated when
   augmentation actually changes the text, and a shared
   `title_from_in_memory_turn` helper that prefers the raw text. Plain
   turns leave the field `None` and fall through to `user_input`
   cleanly.

2. Engine-v2 path (src/bridge/router.rs + crates/ironclaw_engine/).
   `ConversationManager::handle_user_message` now takes an explicit
   `raw_content_for_title: Option<&str>` parameter. The LLM-facing
   `content` stays as the attachment-augmented payload; the raw text
   is stored separately on the `ConversationEntry.metadata` as
   `title_source` (via new `ConversationEntry::user_with_title_source`)
   so downstream title consumers see the user-typed text, not the
   synthesized `<attachments>` block. The router passes `content`
   (raw) as the title source alongside `effective_content`.

3. Atomic title write (src/db/mod.rs + libSQL + PG).
   Replaces the check-then-update pair in `set_title_if_missing` with
   a conditional single-statement UPDATE on both backends. libSQL uses
   `json_extract(metadata, '$.title') IS NULL OR = ''`; PG uses
   `NOT (metadata ? 'title') OR metadata->>'title' = ''`. Adds
   `ConversationStore::set_conversation_title_if_empty` returning
   whether the write landed, so concurrent first-turn callers cannot
   race to overwrite each other.

Tests:
- `test_set_title_if_empty_is_atomic_under_concurrency` (libSQL)
  races two `tokio::join!`'d calls and asserts exactly one wins.
- `test_set_title_if_empty_writes_when_unset` covers NULL metadata
  and empty-string title rows.
- `test_set_title_if_empty_is_atomic_pg` mirrors the race on PG
  (integration tier, `#[ignore]`).
- `handle_user_message_records_raw_title_source` and
  `handle_user_message_plain_text_has_no_title_source_metadata`
  pin the engine-v2 plumbing.
- `test_title_from_in_memory_turn_*` (3 cases) cover the fallback
  helper across augmented, plain, and empty-raw turns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added scope: db/postgres PostgreSQL backend scope: hooks Git/event hooks labels Apr 23, 2026
@zmanian zmanian requested a review from serrrfirat April 23, 2026 21:53
@henrypark133 henrypark133 changed the base branch from staging to main May 1, 2026 06:15
@zmanian
Copy link
Copy Markdown
Collaborator Author

zmanian commented May 2, 2026

Update to get this unstuck:

  • Added feec0c0e for the remaining engine-v2 title gap: current main now has Thread.title, so the merged branch derives that title from raw user text while keeping Thread.goal / the first LLM message as the full attachment-augmented execution prompt.
  • Merged current origin/main in d9f6f52d; PR is mergeable again.
  • Resolved the addressed/stale review threads; unresolved review threads are now at 0.

Local verification:

  • cargo test -p ironclaw_engine runtime::conversation::tests
  • cargo check -p ironclaw

The existing re-review request for @serrrfirat is still active.

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

Labels

contributor: core 20+ merged PRs risk: medium Business logic, config, or moderate-risk modules scope: agent Agent core (agent loop, router, scheduler) scope: channel/web Web gateway channel scope: db/postgres PostgreSQL backend scope: db Database trait / abstraction scope: hooks Git/event hooks size: XL 500+ changed lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[QA] New chats get alphanumeric hash names instead of descriptive titles

4 participants