From 8d25e69b6eb41d8b7e50ef7f1624a334cc2e9d27 Mon Sep 17 00:00:00 2001 From: KoNit-K Date: Thu, 17 Sep 2026 20:16:56 +0800 Subject: [PATCH] fix(desktop): use generated paste previews for titles --- agent/title_generator.py | 43 ++++++++++++++++--- agent/turn_context.py | 5 +++ .../src/app/chat/composer/large-paste.ts | 3 ++ .../chat/hooks/use-composer-actions.test.ts | 29 +++++++++++++ .../app/chat/hooks/use-composer-actions.ts | 5 ++- .../hooks/use-prompt-actions/submit.ts | 5 ++- apps/desktop/src/store/composer.ts | 2 + tests/agent/test_title_generator.py | 32 ++++++++++++++ tui_gateway/compute_host.py | 4 +- tui_gateway/compute_host_bridge.py | 9 ++-- tui_gateway/methods_prompt.py | 16 +++++-- tui_gateway/prompt_turn.py | 1 + 12 files changed, 136 insertions(+), 18 deletions(-) diff --git a/agent/title_generator.py b/agent/title_generator.py index 50dc7198f8..d376854d24 100644 --- a/agent/title_generator.py +++ b/agent/title_generator.py @@ -46,6 +46,7 @@ RuntimeValidator = Callable[[], bool] # Text budget handed to the model (Claude Code / OpenClaw converged on 1000). MAX_TITLE_INPUT_CHARS = 1000 +_PASTE_PREVIEW_LABEL = "\n\nPasted content:\n" # Cap on the instant derived title; a raw fragment reads worse the longer it runs. MAX_DERIVED_TITLE_CHARS = 48 # Answer-shaped guard: a tiny model sometimes answers instead of titling; longer is rejected, not truncated. @@ -210,15 +211,36 @@ def _summarize_user_message(user_message: str) -> str: return strip_control_wrappers(user_message if described is None else described) +def build_title_input(user_message: str, title_preview: str | None = None) -> str: + """Combine the opening text with a bounded Desktop-generated paste preview. + + ``title_preview`` is deliberately an explicit, auxiliary-only value: ordinary + attachments never populate it, and it is never returned to the main turn. + Keep enough of a separately typed request to preserve a useful instruction, + then spend the remaining title budget on the beginning of the pasted topic. + """ + message = _summarize_user_message(user_message) + preview = title_preview.strip() if isinstance(title_preview, str) else "" + if not preview: + return message[:MAX_TITLE_INPUT_CHARS] + if not message: + return preview[:MAX_TITLE_INPUT_CHARS] + message_budget = min(len(message), MAX_TITLE_INPUT_CHARS // 2) + preview_budget = MAX_TITLE_INPUT_CHARS - message_budget - len(_PASTE_PREVIEW_LABEL) + if preview_budget <= 0: + return message[:MAX_TITLE_INPUT_CHARS] + return message[:message_budget] + _PASTE_PREVIEW_LABEL + preview[:preview_budget] + + def is_titleable_user_message(user_message: str) -> bool: """False for machine-authored openers and turns that reduce to nothing once scaffolding is stripped.""" return (isinstance(user_message, str) and bool(user_message.strip()) and not user_message.lstrip().startswith(_MACHINE_PREFIXES) and bool(_summarize_user_message(user_message).strip())) -def derive_title(user_message: str) -> Optional[str]: +def derive_title(user_message: str, title_preview: str | None = None) -> Optional[str]: """Instant title: first meaningful line trimmed to a word boundary. No model, never fails.""" - line = " ".join(_first_line(_summarize_user_message(user_message)).split()) + line = " ".join(_first_line(build_title_input(user_message, title_preview)).split()) if len(line) > MAX_DERIVED_TITLE_CHARS: cut = line[:MAX_DERIVED_TITLE_CHARS] space = cut.rfind(" ") @@ -346,6 +368,7 @@ def generate_title( failure_callback: Optional[FailureCallback] = None, main_runtime: dict = None, runtime_validator: Optional[RuntimeValidator] = None, + title_preview: str | None = None, ) -> Optional[str]: """Title from the opening message alone (waiting for the assistant made this slow and bought nothing). ``runtime_validator`` runs right before the request; False skips silently. @@ -363,7 +386,7 @@ def generate_title( return None except Exception: # fail open: a broken validator must not disable titling logger.debug("Title runtime validator raised; proceeding", exc_info=True) - user_snippet = _summarize_user_message(user_message)[:MAX_TITLE_INPUT_CHARS] + user_snippet = build_title_input(user_message, title_preview) if not user_snippet.strip(): return None language = _title_language() @@ -495,6 +518,7 @@ def auto_title_session( main_runtime: dict = None, title_callback: Optional[TitleCallback] = None, runtime_validator: Optional[RuntimeValidator] = None, + title_preview: str | None = None, ) -> None: """Generate and store the model title (daemon-thread target); skips sessions already carrying an ``llm``/``user`` title (a ``derived`` one is expected — upgrading it is the point). Never lets an @@ -515,12 +539,13 @@ def auto_title_session( # (task='title_generation', #23270). set_accounting_context(session_db, session_id) title, source = generate_title( - user_message, failure_callback=failure_callback, main_runtime=main_runtime, runtime_validator=runtime_validator, + user_message, failure_callback=failure_callback, main_runtime=main_runtime, + runtime_validator=runtime_validator, title_preview=title_preview, ), "llm" if title and _is_provisional_greeting_title(title): source = "derived" if not title: # the inline attempt declined collisions; off the critical path the lineage scan is affordable - title, source = derive_title(user_message), "derived" + title, source = derive_title(user_message, title_preview), "derived" if not title: return try: @@ -588,6 +613,7 @@ def maybe_auto_title( main_runtime: dict = None, title_callback: Optional[TitleCallback] = None, runtime_validator: Optional[RuntimeValidator] = None, + title_preview: str | None = None, ) -> None: """Instant inline title, then a daemon-thread upgrade. Call at the START of a turn, before the model.""" if not session_db or not session_id or not user_message: @@ -626,11 +652,14 @@ def maybe_auto_title( # profile whose turn this is: a bare Thread starts with an empty context and lands on the launch # profile under multiplex, titling X's session with the default profile's model and billing its key. from agent.memory_provider import spawn_context_thread + upgrade_kwargs = dict(failure_callback=failure_callback, main_runtime=main_runtime, title_callback=title_callback, + runtime_validator=runtime_validator) + if isinstance(title_preview, str) and title_preview.strip(): + upgrade_kwargs["title_preview"] = title_preview upgrade = spawn_context_thread( auto_title_session, name="auto-title", args=(session_db, session_id, user_message), - kwargs=dict(failure_callback=failure_callback, main_runtime=main_runtime, title_callback=title_callback, - runtime_validator=runtime_validator), + kwargs=upgrade_kwargs, ) _UPGRADE_THREADS.add(upgrade) upgrade.start() diff --git a/agent/turn_context.py b/agent/turn_context.py index b3f5e7f733..62d367d909 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -166,9 +166,13 @@ def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None: # Turn's user message as text; image-only turns yield "" and are skipped. user_text = "" + title_preview = None for msg in reversed(messages or []): if isinstance(msg, dict) and msg.get("role") == "user": user_text = flatten_message_text(msg.get("content")).strip() + metadata = msg.get("display_metadata") + if isinstance(metadata, dict) and isinstance(metadata.get("title_preview"), str): + title_preview = metadata["title_preview"] break if not user_text: return @@ -204,6 +208,7 @@ def _maybe_title_session_at_turn_start(agent: Any, messages: List[Any]) -> None: getattr(agent, "model", None) == main_runtime["model"] and getattr(agent, "provider", None) == main_runtime["provider"] ), + title_preview=title_preview, ) except Exception: logger.debug("Turn-start auto-title dispatch failed", exc_info=True) diff --git a/apps/desktop/src/app/chat/composer/large-paste.ts b/apps/desktop/src/app/chat/composer/large-paste.ts index 127778ccb8..e9b01ebf14 100644 --- a/apps/desktop/src/app/chat/composer/large-paste.ts +++ b/apps/desktop/src/app/chat/composer/large-paste.ts @@ -10,6 +10,9 @@ /** Characters beyond which a plain-text paste becomes a `.txt` attachment. */ export const LARGE_PASTE_ATTACHMENT_THRESHOLD = 3_000 +/** Maximum source text retained exclusively for automatic title generation. */ +export const LARGE_PASTE_TITLE_PREVIEW_CHARS = 1_000 + /** * True when a plain-text paste should be converted into a text attachment * rather than inserted inline. Only sheer size qualifies — rich clipboard diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts index 270d48f444..6d03c6e946 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.test.ts @@ -386,6 +386,35 @@ describe('useComposerActions native image drops', () => { }) }) +describe('useComposerActions generated paste title metadata', () => { + afterEach(() => { + Reflect.deleteProperty(window, 'hermesDesktop') + vi.clearAllMocks() + }) + + it('marks only a Hermes-generated large paste with a bounded title preview', async () => { + const savePastedText = vi.fn(async () => '/tmp/composer-pastes/pasted-content.txt') + const add = vi.fn<(attachment: ComposerAttachment) => void>() + Object.defineProperty(window, 'hermesDesktop', { configurable: true, value: { savePastedText } }) + const { result } = renderHook(() => useComposerActions({ + activeSessionId: null, + currentCwd: '/test', + requestGateway: vi.fn(), + scope: { add, remove: vi.fn(() => null), target: 'main', update: vi.fn(() => true), updateIfCurrent: vi.fn(() => true) } + })) + + const pasted = `Database migration incident\n${'x'.repeat(1_500)}` + await expect(result.current.attachPastedText(pasted)).resolves.toBe(true) + + expect(add).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'file', + path: '/tmp/composer-pastes/pasted-content.txt', + refText: '@file:/tmp/composer-pastes/pasted-content.txt', + titlePreview: pasted.slice(0, 1_000) + })) + }) +}) + describe('attachImagePath thumbnail separation', () => { // Full-resolution data URL the local bridge returns for a pasted screenshot. // Content is irrelevant — the mock bitmap below reports 4000×3000 so the diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts index 7706a47b54..e57ccb72b3 100644 --- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts +++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts @@ -2,7 +2,7 @@ import { useCallback } from 'react' import { requestComposerFocus, requestComposerInsert, requestComposerInsertRefs } from '@/app/chat/composer/focus' import { droppedFileInlineRef } from '@/app/chat/composer/inline-refs' -import { pasteSizeLabel } from '@/app/chat/composer/large-paste' +import { LARGE_PASTE_TITLE_PREVIEW_CHARS, pasteSizeLabel } from '@/app/chat/composer/large-paste' import { formatRefValue } from '@/components/assistant-ui/directive-text' import { useI18n } from '@/i18n' import { attachmentId, contextPath, pathLabel } from '@/lib/chat-runtime' @@ -623,7 +623,8 @@ export function useComposerActions({ label: `${copy.pastedContent} (${pasteSizeLabel(text)})`, detail: contextPath(savedPath, currentCwd), refText: `@file:${formatRefValue(savedPath)}`, - path: savedPath + path: savedPath, + titlePreview: text.slice(0, LARGE_PASTE_TITLE_PREVIEW_CHARS) }) return true diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts index a47ab2e25b..89fb5173f8 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/submit.ts @@ -133,6 +133,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { Boolean(a) ) + const titlePreview = attachments.find(a => typeof a.titlePreview === 'string' && a.titlePreview.trim())?.titlePreview + const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n') const hasImage = attachments.some(a => a.kind === 'image') @@ -774,7 +776,8 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) { // the next turn untouched — without it, losing the settle race // (client saw idle, server still unwinding) redirects or interrupts // the live turn with text the user explicitly queued. - ...(options?.fromQueue && { queued: true }) + ...(options?.fromQueue && { queued: true }), + ...(titlePreview && { title_preview: titlePreview }) }) // On sleep/wake the gateway's in-memory session may have been cleared diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 40519d4a56..3d7d96b99d 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -19,6 +19,8 @@ export interface ComposerAttachment { /** Downscaled data URL for the attachment card and optimistic bubble only. */ thumbnailUrl?: string path?: string + /** Bounded source text from a Hermes-generated large paste, sent only to the title path. */ + titlePreview?: string attachedSessionId?: string /** Set while the file/image bytes are being staged into the session * workspace (remote upload or local stage), and 'error' if that failed. diff --git a/tests/agent/test_title_generator.py b/tests/agent/test_title_generator.py index 0e536874c1..8f8422cbdd 100644 --- a/tests/agent/test_title_generator.py +++ b/tests/agent/test_title_generator.py @@ -5,6 +5,8 @@ from unittest.mock import MagicMock, patch from agent.title_generator import ( + MAX_TITLE_INPUT_CHARS, + build_title_input, generate_title, auto_title_session, maybe_auto_title, @@ -17,6 +19,36 @@ from hermes_state import SessionDB class TestGenerateTitle: """Unit tests for generate_title().""" + @pytest.mark.parametrize( + ("instruction", "paste_preview", "expected_parts"), + [ + ("", "Quarterly incident analysis for the database migration", ["Quarterly incident analysis"]), + ("Analyze this", "Quarterly incident analysis for the database migration", ["Analyze this", "Quarterly incident analysis"]), + ("Prepare the deployment follow-up", "Quarterly incident analysis", ["Prepare the deployment follow-up", "Quarterly incident analysis"]), + ], + ) + def test_title_input_includes_only_generated_paste_preview( + self, instruction, paste_preview, expected_parts + ): + """Regression for #114124: a generated paste remains an attachment for the turn but informs its title.""" + title_input = build_title_input(instruction, paste_preview) + + assert all(part in title_input for part in expected_parts) + assert "@file:" not in title_input + + def test_title_input_keeps_manual_attachment_and_preview_out_of_title_context(self): + title_input = build_title_input("Summarize @file:notes.txt", None) + + assert title_input == "Summarize @file:notes.txt" + assert "Pasted content:" not in title_input + + def test_title_input_budgets_instruction_and_generated_paste_preview(self): + title_input = build_title_input("Describe the release plan", "p" * MAX_TITLE_INPUT_CHARS) + + assert len(title_input) == MAX_TITLE_INPUT_CHARS + assert title_input.startswith("Describe the release plan") + assert title_input.endswith("p" * 20) + diff --git a/tui_gateway/compute_host.py b/tui_gateway/compute_host.py index 3bed5a8163..d3bdaf8724 100644 --- a/tui_gateway/compute_host.py +++ b/tui_gateway/compute_host.py @@ -248,7 +248,9 @@ class ComputeHost: with contextlib.suppress(Exception): server._persist_branch_seed(session) server._run_prompt_submit( - request_id, sid, session, text, display_kind=frame.get("display_kind") or None) + request_id, sid, session, text, display_kind=frame.get("display_kind") or None, + display_metadata=(frame.get("display_metadata") + if isinstance(frame.get("display_metadata"), dict) else None)) run_thread = session.get("_run_thread") if run_thread is not None and hasattr(run_thread, "join"): while run_thread.is_alive(): diff --git a/tui_gateway/compute_host_bridge.py b/tui_gateway/compute_host_bridge.py index 3449a4863e..48c81a20ba 100644 --- a/tui_gateway/compute_host_bridge.py +++ b/tui_gateway/compute_host_bridge.py @@ -49,7 +49,8 @@ def _get_compute_host_supervisor(cfg: dict | None = None): def _compute_host_turn_frame( rid: str, sid: str, session: dict, text: Any, image_paths: list[str] | None = None, - queued_prompt_generation: int | None = None, display_kind: str | None = None) -> dict: + queued_prompt_generation: int | None = None, display_kind: str | None = None, + display_metadata: dict | None = None) -> dict: with session["history_lock"]: history = list(session.get("history", [])) history_version = int(session.get("history_version", 0)) @@ -58,6 +59,7 @@ def _compute_host_turn_frame( "type": "turn.start", "sid": sid, "request_id": rid, "session_key": session.get("session_key") or sid, "text": text, **({"display_kind": display_kind} if display_kind else {}), "history": history, + **({"display_metadata": display_metadata} if display_metadata else {}), "history_version": history_version, "cols": int(session.get("cols", 80) or 80), "cwd": _session_cwd(session), "context_cwd_is_launch_artifact": _context_cwd_is_launch_artifact(session), @@ -247,11 +249,12 @@ def _on_compute_host_turn_done(rid: str, sid: str, session: dict, frame: dict) - def _submit_prompt_to_compute_host( rid: str, sid: str, session: dict, text: Any, image_paths: list[str] | None = None, - queued_prompt_generation: int | None = None, display_kind: str | None = None) -> dict: + queued_prompt_generation: int | None = None, display_kind: str | None = None, + display_metadata: dict | None = None) -> dict: cfg = _load_dashboard_process_isolation_config() frame = _compute_host_turn_frame(rid, sid, session, text, image_paths=image_paths, queued_prompt_generation=queued_prompt_generation, - display_kind=display_kind) + display_kind=display_kind, display_metadata=display_metadata) # Caller JSON-RPC ids may repeat across sockets and turns. Use an opaque # dispatch lifetime token, installed before a fast child can send activity. turn_id = frame["turn_id"] = frame["request_id"] = uuid.uuid4().hex diff --git a/tui_gateway/methods_prompt.py b/tui_gateway/methods_prompt.py index 106b359404..5b40e80dfb 100644 --- a/tui_gateway/methods_prompt.py +++ b/tui_gateway/methods_prompt.py @@ -487,7 +487,9 @@ def _persist_session_row_for_submit(rid, session, text=None, display_kind=None): return error -def _run_after_agent_ready(rid, sid, session, text, display_kind, hosted_terminal_callback, turn_author=None): +def _run_after_agent_ready( + rid, sid, session, text, display_kind, display_metadata, hosted_terminal_callback, turn_author=None +): """Turn thread body: patient wait for a deferred build (a slow build must not eat the accepted in-flight message), then run.""" # The wait delivers the prompt when the still-running build completes, honors a cancel promptly, notices @@ -516,7 +518,7 @@ def _run_after_agent_ready(rid, sid, session, text, display_kind, hosted_termina else "Session no longer running before the agent was ready")}) return _run_prompt_submit( - rid, sid, session, text, display_kind=display_kind, + rid, sid, session, text, display_kind=display_kind, display_metadata=display_metadata, terminal_callback=hosted_terminal_callback, turn_author=turn_author) @@ -568,6 +570,12 @@ def _(rid, params: dict) -> dict: # Off-screen sends (widget intents) type the row so no client renders a bubble; # whitelisted to "hidden" — this RPC must not mint kinds. display_kind = "hidden" if params.get("display_kind") == "hidden" else None + title_preview = params.get("title_preview") + display_metadata = ( + {"title_preview": title_preview[:1000]} + if isinstance(title_preview, str) and title_preview.strip() + else None + ) if (stopped := _typed_stop_phrase_response(rid, text)) is not None: return stopped if params.get("interrupted"): @@ -657,7 +665,7 @@ def _(rid, params: dict) -> dict: logger.debug("isolated compute turns carry no author yet; the turn from %s runs unattributed", turn_author.get("id")) isolated_response = _submit_prompt_to_compute_host( - rid, sid, session, text, display_kind=display_kind) + rid, sid, session, text, display_kind=display_kind, display_metadata=display_metadata) if not isolated_response.get("error"): # The truncation already happened inline above (memory + DB). isolated_response["result"].update(survivor_fields) @@ -680,7 +688,7 @@ def _(rid, params: dict) -> dict: _start_agent_build(sid, session) run_thread = threading.Thread( target=lambda: _run_after_agent_ready( - rid, sid, session, text, display_kind, hosted_terminal_callback, turn_author), + rid, sid, session, text, display_kind, display_metadata, hosted_terminal_callback, turn_author), daemon=True) # Handle lets session.interrupt tell a live turn from a stuck `running` flag. session["_run_thread"] = run_thread diff --git a/tui_gateway/prompt_turn.py b/tui_gateway/prompt_turn.py index 0d027f2723..b06a99dade 100644 --- a/tui_gateway/prompt_turn.py +++ b/tui_gateway/prompt_turn.py @@ -614,6 +614,7 @@ def _invoke_agent( run_kwargs["task_id"] = session["session_key"] if display_kind and "persist_user_display_kind" in run_params: run_kwargs["persist_user_display_kind"] = display_kind + if display_metadata and "persist_user_display_metadata" in run_params: run_kwargs["persist_user_display_metadata"] = display_metadata if turn_author and "turn_author" in run_params: run_kwargs["turn_author"] = turn_author