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,
|
busyRef,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
handleSkinCommand,
|
handleSkinCommand,
|
||||||
|
refreshSessions,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
startFreshSessionDraft,
|
startFreshSessionDraft,
|
||||||
|
|||||||
@@ -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,
|
setAwaitingResponse,
|
||||||
setBusy,
|
setBusy,
|
||||||
setMessages,
|
setMessages,
|
||||||
|
setSessions,
|
||||||
setYoloActive
|
setYoloActive
|
||||||
} from '@/store/session'
|
} from '@/store/session'
|
||||||
|
|
||||||
import type { ClientSessionState, ImageAttachResponse, SlashExecResponse } from '../../types'
|
import type { ClientSessionState, ImageAttachResponse, SessionTitleResponse, SlashExecResponse } from '../../types'
|
||||||
|
|
||||||
function blobToDataUrl(blob: Blob): Promise<string> {
|
function blobToDataUrl(blob: Blob): Promise<string> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@@ -77,6 +78,7 @@ interface PromptActionsOptions {
|
|||||||
branchCurrentSession: () => Promise<boolean>
|
branchCurrentSession: () => Promise<boolean>
|
||||||
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
createBackendSessionForSend: (preview?: string | null) => Promise<string | null>
|
||||||
handleSkinCommand: (arg: string) => string
|
handleSkinCommand: (arg: string) => string
|
||||||
|
refreshSessions: () => Promise<void>
|
||||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||||
startFreshSessionDraft: () => void
|
startFreshSessionDraft: () => void
|
||||||
@@ -141,6 +143,7 @@ export function usePromptActions({
|
|||||||
branchCurrentSession,
|
branchCurrentSession,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
handleSkinCommand,
|
handleSkinCommand,
|
||||||
|
refreshSessions,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
selectedStoredSessionIdRef,
|
selectedStoredSessionIdRef,
|
||||||
startFreshSessionDraft,
|
startFreshSessionDraft,
|
||||||
@@ -455,6 +458,50 @@ export function usePromptActions({
|
|||||||
const renderSlashOutput = (text: string) =>
|
const renderSlashOutput = (text: string) =>
|
||||||
appendSessionTextMessage(sessionId, 'system', recordInput ? slashStatusText(command, text) : text)
|
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') {
|
if (normalizedName === 'skin') {
|
||||||
renderSlashOutput(handleSkinCommand(arg))
|
renderSlashOutput(handleSkinCommand(arg))
|
||||||
|
|
||||||
@@ -555,6 +602,7 @@ export function usePromptActions({
|
|||||||
busyRef,
|
busyRef,
|
||||||
createBackendSessionForSend,
|
createBackendSessionForSend,
|
||||||
handleSkinCommand,
|
handleSkinCommand,
|
||||||
|
refreshSessions,
|
||||||
requestGateway,
|
requestGateway,
|
||||||
startFreshSessionDraft,
|
startFreshSessionDraft,
|
||||||
submitPromptText
|
submitPromptText
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export interface SlashExecResponse {
|
|||||||
warning?: string
|
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 {
|
export interface ExecCommandDispatchResponse {
|
||||||
type: 'exec' | 'plugin'
|
type: 'exec' | 'plugin'
|
||||||
output?: string
|
output?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user