diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 044cecc62..d59eb1935 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -230,6 +230,11 @@ the API-owned action audit records `executing` before dispatch and the terminal execution result afterward. Dry-run-only plans remain planning evidence only; lifecycle surfaces must not present them as executable, dispatch them through agent-local command paths, or bypass the API fail-closed execution gate. +Assistant session rename through `PATCH /api/ai/sessions/{id}` follows that +same browser-safe history boundary. Lifecycle surfaces, MCP adapters, and +agents may display the updated title as human navigation metadata, but they +must not treat a renamed title as command authorization, host identity, +enrollment state, capability evidence, or install-token disclosure. Agent lifecycle consumers of `/api/agent/events` and `/api/agent/resource-context/{id}` must also honor the shared API command payload boundary: API tokens with monitoring/read scope receive diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a3decd7ad..af9f3aa34 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -751,6 +751,17 @@ runtime cost control, and shared AI transport surfaces. picker must focus search, and each result must be a keyboard-addressable option with list navigation and a named delete action instead of a mouse-only row. + Assistant session rename is part of that same source-backed session + workflow. The referenced OpenCode source in + `packages/opencode/src/cli/cmd/tui/component/dialog-session-list.tsx` + imports `DialogSessionRename` and invokes the runtime session rename action + from the session list, so Pulse must not leave rename as local picker state. + Pulse owns the equivalent through `PATCH /api/ai/sessions/{id}`, + `chat.Service.RenameSession`, and `SessionStore.Rename`; the drawer picker + may render the edit inline, but saving must persist the normalized title, + return the updated `ChatSession`, update the visible row without closing the + picker, and leave messages, handoff context, approvals, and tool evidence + unchanged. The empty Assistant drawer may surface recent non-empty sessions as direct resume actions using the backend session list already owned by the drawer; it must not create a parallel recent-chat store or product-authored prompt diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 765f12a6b..d08d3b082 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -437,6 +437,15 @@ payload shape change when the portal presents compact client rows. remediation details, and frontend clients must route searchable picker requests through the shared `AIChatAPI.listSessions({ search, limit })` helper rather than inventing a parallel local history endpoint. + `PATCH /api/ai/sessions/{id}` owns the Assistant session-title mutation + contract. The request body is limited to a user-visible `title`; the + handler must reject empty titles, the chat store must normalize whitespace + and bound persisted title length, and the response must be the updated + browser-safe `ChatSession` projection. Rename must not expose or mutate + stored prompts, messages, provider reasoning, model handoff context, + approvals, action state, or tool evidence. Browser clients must call the + shared `AIChatAPI.renameSession(sessionId, title)` helper so path encoding + and JSON body shape stay canonical. Resource-context follow-up turns are different from Patrol-run rehydration: browser-safe `handoff_metadata.kind=resource_context` must not replace a stored rich handoff envelope with a partial metadata-only envelope, and the diff --git a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md index 872075080..89993ec60 100644 --- a/docs/release-control/v6/internal/subsystems/performance-and-scalability.md +++ b/docs/release-control/v6/internal/subsystems/performance-and-scalability.md @@ -231,6 +231,11 @@ regression protection. before returning it to AI runtime, but it must not scan run history broadly, hydrate resource inventories, call models, or perform persistence fan-out as part of router setup or generic request admission. + Assistant session rename routing follows the same bounded router rule: + `PATCH /api/ai/sessions/{id}` may branch to the AI handler and relay-mobile + capability check, but router setup and auth gating must not scan session + files, load transcripts, hydrate handoff context, or inspect model/tool + history before the route-local handler owns the single title mutation. Report branding follows the same bounded-work rule. Reporting handlers may read provider-default env configuration and a tenant-local system-settings record when a report is generated, but router setup and normal protected diff --git a/docs/release-control/v6/internal/subsystems/relay-runtime.md b/docs/release-control/v6/internal/subsystems/relay-runtime.md index 48ffd9992..8478e45f9 100644 --- a/docs/release-control/v6/internal/subsystems/relay-runtime.md +++ b/docs/release-control/v6/internal/subsystems/relay-runtime.md @@ -121,6 +121,12 @@ surface too. The dedicated `relay:mobile:access` credential may only reach the explicit runtime route inventory in `internal/api/relay_mobile_capability.go`, and expanding that inventory is governed L7 work rather than a router-local compatibility tweak. +Assistant session rename is part of that explicit mobile relay runtime +inventory: `PATCH /api/ai/sessions/{session_id}` may pass through +`relay:mobile:access` with `ai:chat` scope because it mutates only the +browser-safe session title projection. The relay inventory must not widen that +route into transcript, provider-context, approval, action-execution, or raw +tool-evidence mutation authority. The route that mints that dedicated credential is also part of the paid Relay boundary. `POST /api/security/tokens/relay-mobile` lives in the shared auth/security router, but it must require the paid `relay` entitlement before diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index 1613d0d7b..f6742c275 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -170,6 +170,12 @@ controls as normal product settings. read-only Agent-context boundary. Router glue may connect providers, but it must not become an alternate command path, raw provider-command path, config path, environment path, or secret-bearing metadata path. + Assistant session rename routing through `PATCH /api/ai/sessions/{id}` + stays on that same auth/scope boundary: the route may accept only a + user-visible title mutation, must not expose transcript contents, + provider-bound model context, tool evidence, approvals, or action state, + and must not treat title text as trusted secret-bearing or command-bearing + input. ## Forbidden Paths diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index a417615f0..7ca72a73b 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -209,6 +209,12 @@ and recent action counts may help explain a workload or protected service, but they must not create restore authority, backup visibility, storage-local approval policy, or a bypass around the API-owned action and recovery contracts. +Assistant session rename through `PATCH /api/ai/sessions/{id}` is also only +browser-safe history metadata for storage/recovery consumers. A renamed +conversation may make protected-item investigation easier to find, but title +text must not become backup coverage evidence, restore entitlement, +storage-owner identity, approval policy, or a provider-local recovery command +handoff. Storage and recovery may also consume unified-resource TrueNAS app and VM metadata, TrueNAS native service metadata, plus TrueNAS network-share metadata, as read-only workload, system, and storage-access context when explaining diff --git a/frontend-modern/src/api/__tests__/aiChat.test.ts b/frontend-modern/src/api/__tests__/aiChat.test.ts index 54f928898..0563b482e 100644 --- a/frontend-modern/src/api/__tests__/aiChat.test.ts +++ b/frontend-modern/src/api/__tests__/aiChat.test.ts @@ -138,11 +138,7 @@ describe('AIChatAPI', () => { await advancePaintCheckpoint(); await streamPromise; - expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual([ - 'content', - 'content', - 'done', - ]); + expect(onEvent.mock.calls.map(([event]) => event.type)).toEqual(['content', 'content', 'done']); expect(releaseLock).toHaveBeenCalledTimes(1); }); @@ -198,6 +194,27 @@ describe('AIChatAPI', () => { await expect(AIChatAPI.listSessions()).resolves.toEqual([]); }); + it('renames sessions through the session root endpoint', async () => { + const renamed = { + id: 'session/root', + title: 'Renamed session', + created_at: '2026-06-06T10:00:00Z', + updated_at: '2026-06-06T10:05:00Z', + message_count: 2, + }; + apiFetchJSONMock.mockResolvedValueOnce(renamed); + + await expect(AIChatAPI.renameSession('session/root', 'Renamed session')).resolves.toEqual( + renamed, + ); + + expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/sessions/session%2Froot', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: 'Renamed session' }), + }); + }); + it('preserves restored Assistant tool evidence on session messages', async () => { const messages = [ { diff --git a/frontend-modern/src/api/aiChat.ts b/frontend-modern/src/api/aiChat.ts index 42c4e9cc6..f82e1d4e8 100644 --- a/frontend-modern/src/api/aiChat.ts +++ b/frontend-modern/src/api/aiChat.ts @@ -219,6 +219,15 @@ export class AIChatAPI { }); } + // Rename a session + static async renameSession(sessionId: string, title: string): Promise { + return apiFetchJSON(`${this.baseUrl}/sessions/${encodeURIComponent(sessionId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title }), + }) as Promise; + } + // Get messages for a session static async getMessages(sessionId: string): Promise { return apiFetchJSON( diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 75a4b9e5c..878269319 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -88,6 +88,13 @@ const { message_count: 0, }), deleteSession: vi.fn().mockResolvedValue(undefined), + renameSession: vi.fn().mockResolvedValue({ + id: 'renamed-session', + title: 'Renamed session', + created_at: '2026-06-06T12:30:00Z', + updated_at: '2026-06-06T12:35:00Z', + message_count: 2, + }), forkSession: vi.fn().mockResolvedValue({ id: 'forked-session', title: 'Forked session', @@ -422,6 +429,13 @@ beforeEach(() => { }); mockAIChatAPI.getStatus.mockResolvedValue({ running: true }); mockAIChatAPI.listSessions.mockResolvedValue([]); + mockAIChatAPI.renameSession.mockResolvedValue({ + id: 'renamed-session', + title: 'Renamed session', + created_at: '2026-06-06T12:30:00Z', + updated_at: '2026-06-06T12:35:00Z', + message_count: 2, + }); mockAIChatAPI.forkSession.mockResolvedValue({ id: 'forked-session', title: 'Forked session', @@ -598,7 +612,9 @@ describe('AIChat', () => { 4000, ); - fireEvent.click(screen.getByRole('button', { name: 'Close Assistant transcript copy panel' })); + fireEvent.click( + screen.getByRole('button', { name: 'Close Assistant transcript copy panel' }), + ); await waitFor(() => { expect(screen.queryByLabelText('Assistant transcript')).not.toBeInTheDocument(); @@ -1608,7 +1624,9 @@ describe('AIChat', () => { fireEvent.click(screen.getByRole('option', { name: /Run \/sessions/ })); await waitFor(() => { - expect(screen.getByRole('dialog', { name: 'Pulse Assistant sessions' })).toBeInTheDocument(); + expect( + screen.getByRole('dialog', { name: 'Pulse Assistant sessions' }), + ).toBeInTheDocument(); }); expect(mockAIChatAPI.listSessions).toHaveBeenCalledWith({ limit: 30 }); expect(mockChat.sendMessage).not.toHaveBeenCalled(); @@ -1626,7 +1644,9 @@ describe('AIChat', () => { fireEvent.keyDown(textarea, { key: 'Escape' }); await waitFor(() => { - expect(screen.queryByRole('listbox', { name: 'Assistant commands' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('listbox', { name: 'Assistant commands' }), + ).not.toBeInTheDocument(); }); expect(textarea.value).toBe('/'); expect(mockChat.sendMessage).not.toHaveBeenCalled(); @@ -1660,7 +1680,9 @@ describe('AIChat', () => { fireEvent.keyDown(textarea, { key: 'Enter' }); await waitFor(() => { - expect(screen.getByRole('dialog', { name: 'Pulse Assistant sessions' })).toBeInTheDocument(); + expect( + screen.getByRole('dialog', { name: 'Pulse Assistant sessions' }), + ).toBeInTheDocument(); }); expect(mockAIChatAPI.listSessions).toHaveBeenCalledWith({ limit: 30 }); expect(mockChat.sendMessage).not.toHaveBeenCalled(); @@ -2535,6 +2557,65 @@ describe('AIChat', () => { }); }); + it('renames sessions inline from the picker', async () => { + mockAIChatAPI.listSessions.mockResolvedValue([ + { id: 's1', title: 'Session One', created_at: '', updated_at: '', message_count: 5 }, + ]); + mockAIChatAPI.renameSession.mockResolvedValue({ + id: 's1', + title: 'Renamed session', + created_at: '', + updated_at: '2026-06-06T12:35:00Z', + message_count: 5, + }); + renderChat(); + await waitFor(() => { + expect(mockAIChatAPI.listSessions).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByTitle('Pulse Assistant sessions')); + await screen.findByText('Session One'); + + fireEvent.click( + screen.getByRole('button', { name: 'Rename Assistant session: Session One' }), + ); + const titleInput = screen.getByLabelText('New title for Session One'); + expect(titleInput).toHaveValue('Session One'); + + fireEvent.input(titleInput, { target: { value: 'Renamed session' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Assistant session title' })); + + await waitFor(() => { + expect(mockAIChatAPI.renameSession).toHaveBeenCalledWith('s1', 'Renamed session'); + }); + expect(screen.getByText('Renamed session')).toBeInTheDocument(); + expect(screen.queryByText('Session One')).not.toBeInTheDocument(); + }); + + it('keeps empty session rename drafts local', async () => { + mockAIChatAPI.listSessions.mockResolvedValue([ + { id: 's1', title: 'Session One', created_at: '', updated_at: '', message_count: 5 }, + ]); + renderChat(); + await waitFor(() => { + expect(mockAIChatAPI.listSessions).toHaveBeenCalled(); + }); + + fireEvent.click(screen.getByTitle('Pulse Assistant sessions')); + await screen.findByText('Session One'); + + fireEvent.click( + screen.getByRole('button', { name: 'Rename Assistant session: Session One' }), + ); + const titleInput = screen.getByLabelText('New title for Session One'); + fireEvent.input(titleInput, { target: { value: ' ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Save Assistant session title' })); + + expect(mockAIChatAPI.renameSession).not.toHaveBeenCalled(); + expect(mockNotificationStore.error).toHaveBeenCalledWith('Session title cannot be empty'); + expect(titleInput).toBeInTheDocument(); + }); + it('marks the current working session in the picker', async () => { mockChat.sessionId.mockReturnValue('s1'); mockChat.isLoading.mockReturnValue(true); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index eda85b8ae..04727b709 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -22,6 +22,7 @@ import RotateCwIcon from 'lucide-solid/icons/rotate-cw'; import SettingsIcon from 'lucide-solid/icons/settings'; import XIcon from 'lucide-solid/icons/x'; import BookmarkIcon from 'lucide-solid/icons/bookmark'; +import CheckIcon from 'lucide-solid/icons/check'; import LoaderCircleIcon from 'lucide-solid/icons/loader-circle'; import PlusIcon from 'lucide-solid/icons/plus'; import Trash2Icon from 'lucide-solid/icons/trash-2'; @@ -70,6 +71,11 @@ import { AI_CHAT_PROVIDER_READINESS_RETRY_LABEL, AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF, AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL, + AI_CHAT_RENAME_SESSION_CANCEL_LABEL, + AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE, + AI_CHAT_RENAME_SESSION_ERROR_MESSAGE, + AI_CHAT_RENAME_SESSION_LABEL, + AI_CHAT_RENAME_SESSION_SAVE_LABEL, AI_CHAT_SESSION_MENU_TITLE, AI_CHAT_SESSION_EMPTY_STATE, AI_CHAT_SESSION_LOADING_STATE, @@ -152,6 +158,7 @@ const AI_CHAT_MIN_DOCKED_VIEWPORT_WIDTH = 1200; const AI_CHAT_PROMPT_HISTORY_LIMIT = 100; const AI_CHAT_RECENT_MODEL_LIMIT = 8; const AI_CHAT_PINNED_SESSION_LIMIT = 30; +const AI_CHAT_SESSION_TITLE_MAX_LENGTH = 120; const AI_CHAT_SESSION_SEARCH_DEBOUNCE_MS = 150; const AI_CHAT_SESSION_SEARCH_LIMIT = 30; const STRUCTURED_PATROL_CONTEXT_TARGETS = new Set(['patrol-configuration', 'patrol-run']); @@ -589,10 +596,14 @@ export const AIChat: Component = (props) => { const [sessionSearchResults, setSessionSearchResults] = createSignal(null); const [sessionSearchLoading, setSessionSearchLoading] = createSignal(false); const [sessionSearchError, setSessionSearchError] = createSignal(''); + const [renamingSessionId, setRenamingSessionId] = createSignal(''); + const [sessionRenameDraft, setSessionRenameDraft] = createSignal(''); + const [sessionRenameSaving, setSessionRenameSaving] = createSignal(false); const [sessionDropdownPosition, setSessionDropdownPosition] = createSignal({ top: 0, right: 0 }); const [forkingSession, setForkingSession] = createSignal(false); let sessionButtonRef: HTMLButtonElement | undefined; let sessionSearchInputRef: HTMLInputElement | undefined; + let sessionRenameInputRef: HTMLInputElement | undefined; const sessionOptionRefs = new Map(); let sessionSearchRequestId = 0; const [modelSelectorOpenRequest, setModelSelectorOpenRequest] = createSignal(0); @@ -1102,16 +1113,28 @@ export const AIChat: Component = (props) => { } return sessionRefreshLoading() ? AI_CHAT_SESSION_LOADING_STATE : ''; }); + const resetSessionRename = () => { + setRenamingSessionId(''); + setSessionRenameDraft(''); + setSessionRenameSaving(false); + }; const resetSessionSearch = () => { sessionSearchRequestId += 1; setSessionSearchQuery(''); setSessionSearchResults(null); setSessionSearchLoading(false); setSessionSearchError(''); + resetSessionRename(); }; const focusSessionSearch = () => { queueMicrotask(() => sessionSearchInputRef?.focus()); }; + const focusSessionRenameInput = () => { + queueMicrotask(() => { + sessionRenameInputRef?.focus(); + sessionRenameInputRef?.select(); + }); + }; const findKnownSession = (sessionId: string) => sessions().find((candidate) => candidate.id === sessionId) || @@ -1368,8 +1391,7 @@ export const AIChat: Component = (props) => { const upsertSession = (session: ChatSession) => { setSessions((prev) => [session, ...prev.filter((candidate) => candidate.id !== session.id)]); setSessionSearchResults( - (prev) => - prev && [session, ...prev.filter((candidate) => candidate.id !== session.id)], + (prev) => prev && [session, ...prev.filter((candidate) => candidate.id !== session.id)], ); }; @@ -2624,8 +2646,71 @@ export const AIChat: Component = (props) => { .join(', '); const getSessionPinLabel = (session: ChatSession) => `${isSessionPinned(session.id) ? 'Unpin' : 'Pin'} Assistant session: ${session.title || 'Untitled'}`; + const getSessionRenameLabel = (session: ChatSession) => + `${AI_CHAT_RENAME_SESSION_LABEL}: ${session.title || 'Untitled'}`; const getSessionDeleteLabel = (session: ChatSession) => `Delete Assistant session: ${session.title || 'Untitled'}`; + const isSessionRenaming = (sessionId: string) => renamingSessionId() === sessionId; + const normalizeSessionRenameTitle = (title: string) => { + const normalized = title.trim().replace(/\s+/g, ' '); + const chars = Array.from(normalized); + return chars.length > AI_CHAT_SESSION_TITLE_MAX_LENGTH + ? chars.slice(0, AI_CHAT_SESSION_TITLE_MAX_LENGTH).join('') + : normalized; + }; + + const startRenamingSession = (session: ChatSession, event: Event) => { + event.stopPropagation(); + if (sessionRenameSaving()) return; + setRenamingSessionId(session.id); + setSessionRenameDraft(session.title || ''); + focusSessionRenameInput(); + }; + + const cancelRenamingSession = (event?: Event) => { + event?.preventDefault(); + event?.stopPropagation(); + const sessionId = renamingSessionId(); + resetSessionRename(); + queueMicrotask(() => sessionOptionRefs.get(sessionId)?.focus()); + }; + + const handleSessionRenameKeyDown = (event: KeyboardEvent) => { + event.stopPropagation(); + if (event.key === 'Escape') { + cancelRenamingSession(event); + } + }; + + const submitSessionRename = async (session: ChatSession, event: Event) => { + event.preventDefault(); + event.stopPropagation(); + if (sessionRenameSaving()) return; + + const title = normalizeSessionRenameTitle(sessionRenameDraft()); + if (!title) { + notificationStore.error(AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE); + focusSessionRenameInput(); + return; + } + if (title === normalizeSessionRenameTitle(session.title || '')) { + cancelRenamingSession(); + return; + } + + setSessionRenameSaving(true); + try { + const updatedSession = await AIChatAPI.renameSession(session.id, title); + upsertSession(updatedSession); + resetSessionRename(); + queueMicrotask(() => sessionOptionRefs.get(updatedSession.id)?.focus()); + } catch (error) { + logger.error('[AIChat] Failed to rename session:', error); + notificationStore.error(AI_CHAT_RENAME_SESSION_ERROR_MESSAGE); + setSessionRenameSaving(false); + focusSessionRenameInput(); + } + }; // Load session const handleLoadSession = async (sessionId: string) => { @@ -2940,101 +3025,175 @@ export const AIChat: Component = (props) => { : '' }`} > - -
- + } + > +
submitSessionRename(session, event)} + onClick={(event) => event.stopPropagation()} > - - - -
+
+ { + sessionRenameInputRef = input; + }} + value={sessionRenameDraft()} + onInput={(event) => + setSessionRenameDraft(event.currentTarget.value) + } + onKeyDown={handleSessionRenameKeyDown} + maxLength={AI_CHAT_SESSION_TITLE_MAX_LENGTH} + disabled={sessionRenameSaving()} + aria-label={`New title for ${session.title || 'Untitled'}`} + class="min-w-0 flex-1 rounded border border-blue-300 bg-surface px-2 py-1 text-sm font-medium text-base-content outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:opacity-70 dark:border-blue-800" + /> + + +
+ + + +
+ + + +
+
)} diff --git a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts index df0195c96..2b46daa69 100644 --- a/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/aiChatPresentation.test.ts @@ -25,6 +25,11 @@ import { AI_CHAT_PROVIDER_READINESS_SETTINGS_HREF, AI_CHAT_PROVIDER_READINESS_SETTINGS_LABEL, AI_CHAT_QUESTION_CARD_TITLE, + AI_CHAT_RENAME_SESSION_CANCEL_LABEL, + AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE, + AI_CHAT_RENAME_SESSION_ERROR_MESSAGE, + AI_CHAT_RENAME_SESSION_LABEL, + AI_CHAT_RENAME_SESSION_SAVE_LABEL, AI_CHAT_SESSION_EMPTY_STATE, AI_CHAT_SESSION_LOADING_STATE, AI_CHAT_SESSION_MENU_TITLE, @@ -55,6 +60,11 @@ describe('aiChatPresentation', () => { expect(AI_CHAT_COPY_LAST_ANSWER_LABEL).toBe('Copy last Assistant answer'); expect(AI_CHAT_COPY_LAST_ANSWER_SUCCESS_MESSAGE).toBe('Assistant answer copied'); expect(AI_CHAT_COPY_LAST_ANSWER_ERROR_MESSAGE).toBe('Failed to copy Assistant answer'); + expect(AI_CHAT_RENAME_SESSION_LABEL).toBe('Rename Assistant session'); + expect(AI_CHAT_RENAME_SESSION_SAVE_LABEL).toBe('Save Assistant session title'); + expect(AI_CHAT_RENAME_SESSION_CANCEL_LABEL).toBe('Cancel Assistant session rename'); + expect(AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE).toBe('Session title cannot be empty'); + expect(AI_CHAT_RENAME_SESSION_ERROR_MESSAGE).toBe('Failed to rename Assistant session'); expect(AI_CHAT_LAUNCHER_ARIA_LABEL).toBe('Expand Pulse Assistant'); expect(AI_CHAT_CLOSE_LABEL).toBe('Close Pulse Assistant'); expect(AI_CHAT_SESSION_MENU_TITLE).toBe('Pulse Assistant sessions'); diff --git a/frontend-modern/src/utils/aiChatPresentation.ts b/frontend-modern/src/utils/aiChatPresentation.ts index 6765684fd..097298498 100644 --- a/frontend-modern/src/utils/aiChatPresentation.ts +++ b/frontend-modern/src/utils/aiChatPresentation.ts @@ -16,6 +16,11 @@ export const AI_CHAT_FORK_SESSION_LOAD_ERROR_MESSAGE = 'Forked Assistant session but failed to load it'; export const AI_CHAT_FORK_SESSION_ERROR_MESSAGE = 'Failed to fork Assistant session'; export const AI_CHAT_FORK_SESSION_SUCCESS_MESSAGE = 'Assistant session forked'; +export const AI_CHAT_RENAME_SESSION_LABEL = 'Rename Assistant session'; +export const AI_CHAT_RENAME_SESSION_SAVE_LABEL = 'Save Assistant session title'; +export const AI_CHAT_RENAME_SESSION_CANCEL_LABEL = 'Cancel Assistant session rename'; +export const AI_CHAT_RENAME_SESSION_EMPTY_MESSAGE = 'Session title cannot be empty'; +export const AI_CHAT_RENAME_SESSION_ERROR_MESSAGE = 'Failed to rename Assistant session'; export const AI_CHAT_COPY_LAST_ANSWER_LABEL = 'Copy last Assistant answer'; export const AI_CHAT_COPY_LAST_ANSWER_SUCCESS_MESSAGE = 'Assistant answer copied'; export const AI_CHAT_COPY_LAST_ANSWER_ERROR_MESSAGE = 'Failed to copy Assistant answer'; diff --git a/internal/ai/chat/service.go b/internal/ai/chat/service.go index e67152767..5692f3af8 100644 --- a/internal/ai/chat/service.go +++ b/internal/ai/chat/service.go @@ -3308,6 +3308,19 @@ func (s *Service) DeleteSession(ctx context.Context, sessionID string) error { return sessions.Delete(sessionID) } +// RenameSession updates the user-visible title for a persisted session. +func (s *Service) RenameSession(ctx context.Context, sessionID, title string) (*Session, error) { + s.mu.RLock() + sessions := s.sessions + s.mu.RUnlock() + + if sessions == nil { + return nil, fmt.Errorf("service not started") + } + + return sessions.Rename(sessionID, title) +} + // GetMessages returns messages for a session func (s *Service) GetMessages(ctx context.Context, sessionID string) ([]Message, error) { s.mu.RLock() diff --git a/internal/ai/chat/service_test.go b/internal/ai/chat/service_test.go index a9a21f597..0478f3d19 100644 --- a/internal/ai/chat/service_test.go +++ b/internal/ai/chat/service_test.go @@ -267,6 +267,9 @@ func TestService_SessionWrappers(t *testing.T) { _, err := service.CreateSession(ctx) assert.Error(t, err) assert.Contains(t, err.Error(), "service not started") + _, err = service.RenameSession(ctx, "missing", "Renamed session") + assert.Error(t, err) + assert.Contains(t, err.Error(), "service not started") // Start service err = service.Start(ctx) @@ -303,13 +306,23 @@ func TestService_SessionWrappers(t *testing.T) { require.NoError(t, err) assert.Len(t, list, 1) + renamed, err := service.RenameSession(ctx, session.ID, "Renamed session") + require.NoError(t, err) + require.NotNil(t, renamed) + assert.Equal(t, "Renamed session", renamed.Title) + + list, err = service.ListSessions(ctx) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, "Renamed session", list[0].Title) + // Fork Session forked, err := service.ForkSession(ctx, session.ID) require.NoError(t, err) require.NotNil(t, forked) assert.NotEqual(t, session.ID, forked.ID) assert.Equal(t, 1, forked.MessageCount) - assert.Equal(t, "Forked session", forked.Title) + assert.Equal(t, "Fork of Renamed session", forked.Title) forkedMessages, err := service.GetMessages(ctx, forked.ID) require.NoError(t, err) diff --git a/internal/ai/chat/session.go b/internal/ai/chat/session.go index a61770847..8b97e1744 100644 --- a/internal/ai/chat/session.go +++ b/internal/ai/chat/session.go @@ -360,7 +360,10 @@ func modelContextHandoffSummary(modelContext *sessionModelContext) *SessionHando return summary } -const maxSessionIDLength = 128 +const ( + maxSessionIDLength = 128 + maxSessionTitleRunes = 120 +) var errSessionNotFound = errors.New("session not found") @@ -548,6 +551,37 @@ func (s *SessionStore) Get(id string) (*Session, error) { }, nil } +// Rename updates a session title without touching messages or handoff context. +func (s *SessionStore) Rename(id, title string) (*Session, error) { + normalizedTitle := normalizeSessionTitle(title) + if normalizedTitle == "" { + return nil, fmt.Errorf("session title required") + } + + s.mu.Lock() + defer s.mu.Unlock() + + data, err := s.readSession(id) + if err != nil { + return nil, err + } + + data.Title = normalizedTitle + data.UpdatedAt = time.Now() + if err := s.writeSession(*data); err != nil { + return nil, err + } + + return &Session{ + ID: data.ID, + Title: data.Title, + CreatedAt: data.CreatedAt, + UpdatedAt: data.UpdatedAt, + MessageCount: len(data.Messages), + HandoffSummary: modelContextHandoffSummary(data.ModelContext), + }, nil +} + // Fork clones a persisted session into a new durable session. The copied // messages intentionally preserve their per-session IDs so tool-call/result // relationships remain intact inside the forked transcript. @@ -1179,6 +1213,15 @@ func generateTitle(content string) string { return truncated + "..." } +func normalizeSessionTitle(title string) string { + normalized := strings.Join(strings.Fields(strings.TrimSpace(title)), " ") + runes := []rune(normalized) + if len(runes) <= maxSessionTitleRunes { + return normalized + } + return string(runes[:maxSessionTitleRunes]) +} + // EnsureSession ensures a session exists, creating one if needed func (s *SessionStore) EnsureSession(id string) (*Session, error) { if id == "" { diff --git a/internal/ai/chat/session_test.go b/internal/ai/chat/session_test.go index be250023f..f49210a12 100644 --- a/internal/ai/chat/session_test.go +++ b/internal/ai/chat/session_test.go @@ -59,6 +59,24 @@ func TestSessionStore(t *testing.T) { assert.Contains(t, err.Error(), "session not found") }) + t.Run("Rename", func(t *testing.T) { + session, _ := store.Create() + renamed, err := store.Rename(session.ID, " Renamed\nsession ") + require.NoError(t, err) + assert.Equal(t, "Renamed session", renamed.Title) + + retrieved, err := store.Get(session.ID) + require.NoError(t, err) + assert.Equal(t, "Renamed session", retrieved.Title) + }) + + t.Run("Rename rejects empty title", func(t *testing.T) { + session, _ := store.Create() + _, err := store.Rename(session.ID, " ") + assert.Error(t, err) + assert.Contains(t, err.Error(), "session title required") + }) + t.Run("AddMessage and Title Generation", func(t *testing.T) { session, _ := store.Create() msg := Message{ diff --git a/internal/api/ai_handler.go b/internal/api/ai_handler.go index 27713c21e..400efe25a 100644 --- a/internal/api/ai_handler.go +++ b/internal/api/ai_handler.go @@ -47,6 +47,7 @@ type AIService interface { ListSessions(ctx context.Context) ([]chat.Session, error) CreateSession(ctx context.Context) (*chat.Session, error) DeleteSession(ctx context.Context, sessionID string) error + RenameSession(ctx context.Context, sessionID, title string) (*chat.Session, error) GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) GetModelHandoffFindingID(ctx context.Context, sessionID string) (string, error) GetModelHandoffMetadata(ctx context.Context, sessionID string) (chat.HandoffMetadata, error) @@ -986,6 +987,10 @@ type ChatRequest struct { AutonomousMode *bool `json:"autonomous_mode,omitempty"` } +type RenameSessionRequest struct { + Title string `json:"title"` +} + const ( chatRequestHandoffContextMaxBytes = 16 * 1024 chatRequestHandoffResourceLimit = 8 @@ -2754,6 +2759,40 @@ func (h *AIHandler) HandleDeleteSession(w http.ResponseWriter, r *http.Request, w.WriteHeader(http.StatusNoContent) } +// HandleRenameSession handles PATCH /api/ai/sessions/{id} +func (h *AIHandler) HandleRenameSession(w http.ResponseWriter, r *http.Request, sessionID string) { + ctx := r.Context() + if !h.IsRunning(ctx) { + http.Error(w, "Pulse Assistant is not running", http.StatusServiceUnavailable) + return + } + + svc := h.GetService(ctx) + if svc == nil { + http.Error(w, "Pulse Assistant service not available", http.StatusServiceUnavailable) + return + } + + var req RenameSessionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + if strings.TrimSpace(req.Title) == "" { + http.Error(w, "Session title required", http.StatusBadRequest) + return + } + + session, err := svc.RenameSession(ctx, sessionID, req.Title) + if err != nil { + http.Error(w, sanitizeErrorForClient(err, "Internal server error"), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(session) +} + // HandleMessages handles GET /api/ai/sessions/{id}/messages func (h *AIHandler) HandleMessages(w http.ResponseWriter, r *http.Request, sessionID string) { ctx := r.Context() diff --git a/internal/api/ai_handler_recovery_wiring_test.go b/internal/api/ai_handler_recovery_wiring_test.go index 315d588e1..3ab713f5c 100644 --- a/internal/api/ai_handler_recovery_wiring_test.go +++ b/internal/api/ai_handler_recovery_wiring_test.go @@ -40,6 +40,9 @@ func (s *capturingAIService) CreateSession(ctx context.Context) (*chat.Session, return &chat.Session{ID: "test"}, nil } func (s *capturingAIService) DeleteSession(ctx context.Context, sessionID string) error { return nil } +func (s *capturingAIService) RenameSession(ctx context.Context, sessionID, title string) (*chat.Session, error) { + return &chat.Session{ID: sessionID, Title: title}, nil +} func (s *capturingAIService) GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) { return nil, nil } diff --git a/internal/api/ai_handler_test.go b/internal/api/ai_handler_test.go index a0b590a71..49e0de6d6 100644 --- a/internal/api/ai_handler_test.go +++ b/internal/api/ai_handler_test.go @@ -97,6 +97,14 @@ func (m *MockAIService) DeleteSession(ctx context.Context, sessionID string) err return args.Error(0) } +func (m *MockAIService) RenameSession(ctx context.Context, sessionID, title string) (*chat.Session, error) { + args := m.Called(ctx, sessionID, title) + if args.Get(0) == nil { + return nil, args.Error(1) + } + return args.Get(0).(*chat.Session), args.Error(1) +} + func (m *MockAIService) GetMessages(ctx context.Context, sessionID string) ([]chat.Message, error) { args := m.Called(ctx, sessionID) return args.Get(0).([]chat.Message), args.Error(1) @@ -558,6 +566,47 @@ func TestHandleDeleteSession(t *testing.T) { assert.Equal(t, http.StatusNoContent, w.Code) } +func TestHandleRenameSession(t *testing.T) { + cfg := &config.Config{} + h := newTestAIHandler(cfg, nil, nil) + mockSvc := new(MockAIService) + h.defaultService = mockSvc + + mockSvc.On("IsRunning").Return(true) + mockSvc.On("RenameSession", mock.Anything, "s1", "Renamed session").Return(&chat.Session{ + ID: "s1", + Title: "Renamed session", + }, nil) + + req := httptest.NewRequest("PATCH", "/api/ai/sessions/s1", strings.NewReader(`{"title":"Renamed session"}`)) + w := httptest.NewRecorder() + + h.HandleRenameSession(w, req, "s1") + + assert.Equal(t, http.StatusOK, w.Code) + var got chat.Session + assert.NoError(t, json.NewDecoder(w.Body).Decode(&got)) + assert.Equal(t, "Renamed session", got.Title) + mockSvc.AssertExpectations(t) +} + +func TestHandleRenameSessionRejectsEmptyTitle(t *testing.T) { + cfg := &config.Config{} + h := newTestAIHandler(cfg, nil, nil) + mockSvc := new(MockAIService) + h.defaultService = mockSvc + + mockSvc.On("IsRunning").Return(true) + + req := httptest.NewRequest("PATCH", "/api/ai/sessions/s1", strings.NewReader(`{"title":" "}`)) + w := httptest.NewRecorder() + + h.HandleRenameSession(w, req, "s1") + + assert.Equal(t, http.StatusBadRequest, w.Code) + mockSvc.AssertNotCalled(t, "RenameSession", mock.Anything, mock.Anything, mock.Anything) +} + func TestHandleMessages(t *testing.T) { cfg := &config.Config{} h := newTestAIHandler(cfg, nil, nil) diff --git a/internal/api/relay_mobile_capability.go b/internal/api/relay_mobile_capability.go index 2f112c47e..88ddb7e27 100644 --- a/internal/api/relay_mobile_capability.go +++ b/internal/api/relay_mobile_capability.go @@ -34,6 +34,7 @@ const ( relayMobileRouteSessionCreate relayMobileRuntimeRouteID = "session-create" relayMobileRouteSessionMessages relayMobileRuntimeRouteID = "session-messages" relayMobileRouteSessionAbort relayMobileRuntimeRouteID = "session-abort" + relayMobileRouteSessionRename relayMobileRuntimeRouteID = "session-rename" relayMobileRouteSessionDelete relayMobileRuntimeRouteID = "session-delete" ) @@ -55,6 +56,7 @@ var relayMobileRuntimeRouteOrder = []relayMobileRuntimeRouteID{ relayMobileRouteSessionCreate, relayMobileRouteSessionMessages, relayMobileRouteSessionAbort, + relayMobileRouteSessionRename, relayMobileRouteSessionDelete, } @@ -161,6 +163,12 @@ var relayMobileRuntimeRouteSpecs = map[relayMobileRuntimeRouteID]relayMobileRunt path: "/api/ai/sessions/{session_id}/abort", requiredScope: config.ScopeAIChat, }, + relayMobileRouteSessionRename: { + id: relayMobileRouteSessionRename, + method: http.MethodPatch, + path: "/api/ai/sessions/{session_id}", + requiredScope: config.ScopeAIChat, + }, relayMobileRouteSessionDelete: { id: relayMobileRouteSessionDelete, method: http.MethodDelete, diff --git a/internal/api/relay_mobile_capability_test.go b/internal/api/relay_mobile_capability_test.go index 490d28cab..09ae073e1 100644 --- a/internal/api/relay_mobile_capability_test.go +++ b/internal/api/relay_mobile_capability_test.go @@ -56,6 +56,7 @@ func TestRelayMobileRuntimeRouteInventory(t *testing.T) { "POST /api/ai/sessions => ai:chat", "GET /api/ai/sessions/{session_id}/messages => ai:chat", "POST /api/ai/sessions/{session_id}/abort => ai:chat", + "PATCH /api/ai/sessions/{session_id} => ai:chat", "DELETE /api/ai/sessions/{session_id} => ai:chat", } diff --git a/internal/api/router.go b/internal/api/router.go index 638941825..88f2d5db8 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -892,6 +892,11 @@ func (r *Router) routeAISessions(w http.ResponseWriter, req *http.Request) { // Handle session-level operations switch req.Method { + case http.MethodPatch: + if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionRename) { + return + } + r.aiHandler.HandleRenameSession(w, req, sessionID) case http.MethodDelete: if !ensureRelayMobileRuntimeRoute(w, req, relayMobileRouteSessionDelete) { return diff --git a/internal/api/router_routes_additional_test.go b/internal/api/router_routes_additional_test.go index d5c711836..65986f504 100644 --- a/internal/api/router_routes_additional_test.go +++ b/internal/api/router_routes_additional_test.go @@ -100,6 +100,29 @@ func TestRouteAISessions_DeleteSession(t *testing.T) { mockSvc.AssertExpectations(t) } +func TestRouteAISessions_RenameSession(t *testing.T) { + mockSvc := &MockAIService{} + mockSvc.On("IsRunning").Return(true) + mockSvc.On("RenameSession", mock.Anything, "session-1", "Renamed session").Return(&chat.Session{ + ID: "session-1", + Title: "Renamed session", + }, nil) + + handler := &AIHandler{} + setUnexportedField(t, handler, "defaultService", mockSvc) + + router := &Router{aiHandler: handler} + req := httptest.NewRequest(http.MethodPatch, "/api/ai/sessions/session-1", strings.NewReader(`{"title":"Renamed session"}`)) + rec := httptest.NewRecorder() + + router.routeAISessions(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + mockSvc.AssertExpectations(t) +} + func TestRouteAISessions_Abort(t *testing.T) { mockSvc := &MockAIService{} mockSvc.On("IsRunning").Return(true) diff --git a/internal/api/security_regression_test.go b/internal/api/security_regression_test.go index 454713b1a..77de77df5 100644 --- a/internal/api/security_regression_test.go +++ b/internal/api/security_regression_test.go @@ -1543,6 +1543,7 @@ func TestRelayMobileAccessScopeAllowsGovernedMobileRuntimeEndpoints(t *testing.T {method: http.MethodGet, path: "/api/ai/sessions", body: ""}, {method: http.MethodGet, path: "/api/ai/sessions/session-1/messages", body: ""}, {method: http.MethodPost, path: "/api/ai/sessions/session-1/abort", body: `{}`}, + {method: http.MethodPatch, path: "/api/ai/sessions/session-1", body: `{"title":"Renamed session"}`}, {method: http.MethodDelete, path: "/api/ai/sessions/session-1", body: ""}, }