fix(desktop): rename session via session.title RPC so /title works (#39410)
The desktop `/title <name>` command 404s with "Session not found" on every platform (reported on Windows in #38508). Root cause: `session.create` returns two distinct ids — a *runtime* session id (held in `activeSessionIdRef`) and a `stored_session_id` (the DB `sessions.id`) — and deliberately does NOT persist a DB row until the first turn. Routing `/title` through the REST `PATCH /api/sessions/{id}` endpoint (as #38576 proposed) resolves the id against the `sessions` table, so the runtime id — or any brand-new, not-yet-persisted session — never resolves and returns 404. This is an id-type mismatch, not a Windows file-locking quirk, so it fails on macOS and Linux too. Fix: route `/title <name>` through the gateway's `session.title` RPC — the exact path the TUI already uses (`ui-tui/.../slash/commands/core.ts`). The RPC maps the runtime id to the in-memory session, writes through the gateway's own DB connection, and queues the title (`pending: true`) when the row isn't persisted yet, so it works for a fresh chat. The sidebar is then refreshed via the existing `refreshSessions()` plumbing. Keeps the sidebar-refresh wiring and `refreshSessions` threading from #38576; replaces only the broken REST/slash-worker write path. A bare `/title` (no arg) still falls through to the worker to show the current title. Tests rewritten to assert `session.title` routing with the runtime-vs- stored id distinction (which the original mock collapsed), plus the queued/`pending` fresh-chat case and the error path. Supersedes #38576. Fixes #38508. Co-authored-by: xxxigm <54813621+xxxigm@users.noreply.github.com>
This commit is contained in:
@ -506,6 +506,7 @@ export function DesktopController() {
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
|
||||
166
apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
Normal file
166
apps/desktop/src/app/session/hooks/use-prompt-actions.test.tsx
Normal file
@ -0,0 +1,166 @@
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { $sessions, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import { usePromptActions } from './use-prompt-actions'
|
||||
|
||||
vi.mock('@/hermes', () => ({
|
||||
transcribeAudio: vi.fn()
|
||||
}))
|
||||
|
||||
// The active id the desktop holds is the *runtime* session id from
|
||||
// session.create — deliberately distinct from the stored DB id here, because
|
||||
// that mismatch is the bug: the REST renameSession endpoint resolves against
|
||||
// the stored sessions table and 404s on a runtime id. session.title accepts
|
||||
// the runtime id directly.
|
||||
const RUNTIME_SESSION_ID = 'rt-abc123'
|
||||
|
||||
function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
|
||||
return {
|
||||
ended_at: null,
|
||||
id: RUNTIME_SESSION_ID,
|
||||
input_tokens: 0,
|
||||
is_active: true,
|
||||
last_active: 0,
|
||||
message_count: 3,
|
||||
model: null,
|
||||
output_tokens: 0,
|
||||
preview: null,
|
||||
source: null,
|
||||
started_at: 0,
|
||||
title: 'Old title',
|
||||
tool_call_count: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
interface HarnessHandle {
|
||||
submitText: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function Harness({
|
||||
onReady,
|
||||
refreshSessions,
|
||||
requestGateway
|
||||
}: {
|
||||
onReady: (handle: HarnessHandle) => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
}) {
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const busyRef = { current: false }
|
||||
|
||||
const actions = usePromptActions({
|
||||
activeSessionId: RUNTIME_SESSION_ID,
|
||||
activeSessionIdRef,
|
||||
branchCurrentSession: async () => true,
|
||||
busyRef,
|
||||
createBackendSessionForSend: async () => RUNTIME_SESSION_ID,
|
||||
handleSkinCommand: () => '',
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft: () => undefined,
|
||||
sttEnabled: false,
|
||||
updateSessionState: (_sessionId, updater) =>
|
||||
updater({ messages: [], busy: false, awaitingResponse: false } as never)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
onReady({ submitText: actions.submitText })
|
||||
}, [actions.submitText, onReady])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
describe('usePromptActions /title', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo()])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renames via the session.title RPC (with the runtime id), updates the sidebar store, and refreshes', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
(method === 'session.title' ? { pending: false, title: 'New title' } : {}) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title New title')
|
||||
|
||||
// Routes through session.title with the runtime session id — NOT the slash
|
||||
// worker (slash.exec) and NOT the REST endpoint. This is the path that
|
||||
// resolves the runtime id and persists reliably across platforms.
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.title', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
title: 'New title'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
||||
expect($sessions.get()[0]?.title).toBe('New title')
|
||||
})
|
||||
|
||||
it('reports the queued state when the session row is not persisted yet', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async (method: string) =>
|
||||
(method === 'session.title' ? { pending: true, title: 'Fresh chat' } : {}) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title Fresh chat')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.title', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
title: 'Fresh chat'
|
||||
})
|
||||
// Even when queued, the sidebar reflects the chosen title optimistically.
|
||||
expect(refreshSessions).toHaveBeenCalledTimes(1)
|
||||
expect($sessions.get()[0]?.title).toBe('Fresh chat')
|
||||
})
|
||||
|
||||
it('falls through to the slash worker for a bare /title (show current title)', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async () => ({ output: 'Title: Old title' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title')
|
||||
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('session.title', expect.anything())
|
||||
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'title' }))
|
||||
})
|
||||
|
||||
it('surfaces a rename error without touching the sidebar store', async () => {
|
||||
const refreshSessions = vi.fn(async () => undefined)
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.title') {
|
||||
throw new Error('Title too long')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
render(<Harness onReady={h => (handle = h)} refreshSessions={refreshSessions} requestGateway={requestGateway} />)
|
||||
|
||||
await handle!.submitText('/title way too long title')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.title', expect.objectContaining({ title: 'way too long title' }))
|
||||
expect(refreshSessions).not.toHaveBeenCalled()
|
||||
expect($sessions.get()[0]?.title).toBe('Old title')
|
||||
})
|
||||
})
|
||||
@ -37,10 +37,11 @@ import {
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setMessages,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
|
||||
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
|
||||
|
||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -77,6 +78,7 @@ interface PromptActionsOptions {
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
||||
handleSkinCommand: (arg: string) => string
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: () => void
|
||||
@ -141,6 +143,7 @@ export function usePromptActions({
|
||||
branchCurrentSession,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
@ -455,6 +458,50 @@ export function usePromptActions({
|
||||
const renderSlashOutput = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
|
||||
|
||||
// /title <name> renames the session. Route through the gateway's
|
||||
// `session.title` RPC — the same path the TUI uses — NOT the REST
|
||||
// renameSession endpoint and NOT the slash worker.
|
||||
//
|
||||
// Why not the slash worker: it's a separate HermesCLI subprocess whose
|
||||
// SQLite write to the shared state.db can silently fail (notably on
|
||||
// Windows), and it never refreshes the sidebar.
|
||||
//
|
||||
// Why not REST renameSession: `sessionId` here is the *runtime* session
|
||||
// id returned by session.create — it is NOT the stored DB `sessions.id`,
|
||||
// and session.create deliberately does not persist a DB row until the
|
||||
// first turn. The REST PATCH endpoint resolves against the sessions
|
||||
// table, so a runtime id (or a brand-new, not-yet-persisted session)
|
||||
// 404s with "Session not found" on every platform. See #38508 / #38576.
|
||||
//
|
||||
// session.title maps the runtime id to the in-memory session, writes
|
||||
// through the gateway's own DB connection, and QUEUES the title
|
||||
// (`pending: true`) when the row isn't persisted yet — so it works for a
|
||||
// fresh chat too. refreshSessions() then pulls the authoritative title
|
||||
// back into the sidebar. A bare `/title` (no arg) still falls through to
|
||||
// the worker to display the current title.
|
||||
if (normalizedName === 'title' && arg) {
|
||||
try {
|
||||
const result = await requestGateway<SessionTitleResponse>('session.title', {
|
||||
session_id: sessionId,
|
||||
title: arg
|
||||
})
|
||||
const finalTitle = (result?.title || arg).trim()
|
||||
const queued = result?.pending === true
|
||||
|
||||
setSessions(prev => prev.map(s => (s.id === sessionId ? { ...s, title: finalTitle || null } : s)))
|
||||
await refreshSessions().catch(() => undefined)
|
||||
renderSlashOutput(
|
||||
finalTitle
|
||||
? `Session title set: ${finalTitle}${queued ? ' (queued while session initializes)' : ''}`
|
||||
: 'Session title cleared.'
|
||||
)
|
||||
} catch (err) {
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (normalizedName === 'skin') {
|
||||
renderSlashOutput(handleSkinCommand(arg))
|
||||
|
||||
@ -555,6 +602,7 @@ export function usePromptActions({
|
||||
busyRef,
|
||||
createBackendSessionForSend,
|
||||
handleSkinCommand,
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
|
||||
@ -25,6 +25,14 @@ export interface SlashExecResponse {
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export interface SessionTitleResponse {
|
||||
title?: string
|
||||
// True when the session row isn't persisted yet and the title was queued
|
||||
// to be applied on the first turn (see tui_gateway session.title handler).
|
||||
pending?: boolean
|
||||
session_key?: string
|
||||
}
|
||||
|
||||
export interface ExecCommandDispatchResponse {
|
||||
type: 'exec' | 'plugin'
|
||||
output?: string
|
||||
|
||||
Reference in New Issue
Block a user