Przeglądaj źródła

将原生会话改为历史只读

LSamien 1 miesiąc temu
rodzic
commit
9603ed79e7

+ 1 - 9
src/client/App.vue

@@ -5,13 +5,11 @@ import LoadingScreen from '@/components/app/LoadingScreen.vue'
 import LoginView from '@/views/LoginView.vue'
 import PasswordChangeView from '@/views/PasswordChangeView.vue'
 import { useAuthStore } from '@/stores/auth'
-import { useChatStore } from '@/stores/chat'
 import { useKanbanStore } from '@/stores/kanban'
 import { useThemeStore } from '@/stores/theme'
 import AgentIdentityFixture from '@/components/app/AgentIdentityFixture.vue'
 
 const auth = useAuthStore()
-const chat = useChatStore()
 const kanban = useKanbanStore()
 const theme = useThemeStore()
 const route = useRoute()
@@ -25,7 +23,7 @@ const pageTitle = computed(() => {
       ? kanban.selectedBoard?.name || kanban.selectedBoardSlug || '看板'
     : route.path.startsWith('/files')
       ? '文件库'
-      : chat.activeSession?.title || '对话'
+      : '历史记录'
   return `${name} · 夭夭`
 })
 
@@ -37,12 +35,6 @@ watch(() => theme.resolvedTheme, value => {
 
 watch(pageTitle, value => { document.title = value }, { immediate: true })
 
-watch(() => auth.isAuthenticated, authenticated => {
-  if (authenticated) {
-    void chat.connect().catch(() => undefined)
-  }
-})
-
 onMounted(() => {
   if (!agentIdentityFixture) void auth.bootstrap()
 })

+ 1 - 1
src/client/components/app/WorkspaceShell.vue

@@ -119,7 +119,7 @@ function profileTitle(profile?: Profile): string {
 }
 
 const navItems: NavItem[] = [
-  { key: 'chat', label: '原生对话', path: '/chat', icon: 'chat' },
+  { key: 'chat', label: '历史记录', path: '/chat', icon: 'chat' },
   { key: 'groups', label: '聊天', path: '/conversations', icon: 'groups' },
   { key: 'kanban', label: '看板', path: '/kanban', icon: 'board' },
   { key: 'files', label: '文件库', path: '/files', icon: 'files' },

+ 1 - 1
src/client/components/library/ImagePreviewLightbox.vue

@@ -62,7 +62,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
     >
       <div class="image-preview-actions">
         <a :href="previewMedia.url" download :aria-label="`下载 ${previewMedia.name}`" :title="`下载 ${previewMedia.name}`"><DownloadOutline aria-hidden="true" /></a>
-        <button type="button" :disabled="canAdd === false" :aria-label="addLabel || '添加到聊天'" :title="canAdd === false ? '当前团队不支持添加附件' : addLabel || '添加到聊天'" @click.stop="emit('add', previewMedia)"><AddOutline aria-hidden="true" /></button>
+        <button v-if="canAdd" type="button" :aria-label="addLabel || '添加到聊天'" :title="addLabel || '添加到聊天'" @click.stop="emit('add', previewMedia)"><AddOutline aria-hidden="true" /></button>
         <button type="button" :aria-label="closeLabel || '关闭预览'" :title="closeLabel || '关闭预览'" @click="close"><CloseOutline aria-hidden="true" /></button>
       </div>
       <button v-if="canPrevious" class="image-preview-nav image-preview-nav--previous" type="button" :aria-label="previousLabel || '上一张媒体'" @click.stop="move(-1)"><ChevronBackOutline aria-hidden="true" /></button>

+ 3 - 2
src/client/components/library/PreviewInspector.vue

@@ -7,7 +7,8 @@ import type { UiLibraryItem } from './types'
 const props = withDefaults(defineProps<{
   item: UiLibraryItem
   showClose?: boolean
-}>(), { showClose: false })
+  canAddToComposer?: boolean
+}>(), { showClose: false, canAddToComposer: true })
 const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
 
 const canInline = computed(() => !!props.item.previewUrl)
@@ -125,7 +126,7 @@ onBeforeUnmount(() => officeResizeObserver?.disconnect())
       <div class="preview-header-actions">
         <button v-if="item.sourceSessionId" class="icon-button" type="button" aria-label="跳转到来源消息" title="跳转到来源消息" @click="emit('source', item)"><AppIcon name="external" :size="15" /></button>
         <a v-if="item.downloadUrl || item.previewUrl" class="icon-button" :href="item.downloadUrl || item.previewUrl" download aria-label="下载" title="下载"><AppIcon name="download" :size="16" /></a>
-        <button class="icon-button" type="button" aria-label="加入输入框" title="加入输入框" @click="emit('addToComposer', item)"><AppIcon name="plus" :size="17" /></button>
+        <button v-if="canAddToComposer" class="icon-button" type="button" aria-label="加入输入框" title="加入输入框" @click="emit('addToComposer', item)"><AppIcon name="plus" :size="17" /></button>
         <button v-if="showClose" class="icon-button preview-close" type="button" aria-label="关闭预览" title="关闭预览" @click="emit('close')"><AppIcon name="close" :size="17" /></button>
       </div>
     </header>

+ 2 - 2
src/client/components/library/PreviewModal.vue

@@ -4,7 +4,7 @@ import AppIcon from '@/components/common/AppIcon.vue'
 import PreviewInspector from './PreviewInspector.vue'
 import type { UiLibraryItem } from './types'
 
-const props = withDefaults(defineProps<{ item: UiLibraryItem; items?: UiLibraryItem[] }>(), { items: () => [] })
+const props = withDefaults(defineProps<{ item: UiLibraryItem; items?: UiLibraryItem[]; canAddToComposer?: boolean }>(), { items: () => [], canAddToComposer: true })
 const emit = defineEmits<{ close: []; addToComposer: [item: UiLibraryItem]; source: [item: UiLibraryItem] }>()
 
 const mediaItems = computed(() => {
@@ -59,7 +59,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKeydown))
     <Transition name="preview-modal">
       <div class="preview-modal-layer" @mousedown.self="emit('close')">
         <section class="preview-modal" role="dialog" aria-modal="true" :aria-label="`预览 ${activeItem.title || activeItem.name}`" @pointerdown="startSwipe" @pointerup="finishSwipe" @pointercancel="swipeStart = undefined">
-          <PreviewInspector :item="activeItem" show-close @close="emit('close')" @add-to-composer="emit('addToComposer', $event)" @source="emit('source', $event)" />
+          <PreviewInspector :item="activeItem" show-close :can-add-to-composer="canAddToComposer" @close="emit('close')" @add-to-composer="emit('addToComposer', $event)" @source="emit('source', $event)" />
           <button v-if="canPrevious" class="media-nav media-nav--previous" type="button" aria-label="上一张媒体" title="上一张媒体" @click="previous"><AppIcon name="chevron-left" :size="22" /></button>
           <button v-if="canNext" class="media-nav media-nav--next" type="button" aria-label="下一张媒体" title="下一张媒体" @click="next"><AppIcon class="media-nav__next-icon" name="chevron-left" :size="22" /></button>
         </section>

+ 4 - 2
src/client/components/messages/MessageTimeline.vue

@@ -35,6 +35,7 @@ const props = withDefaults(defineProps<{
   agentStates?: Record<string, 'idle' | 'working' | 'waiting' | 'loading' | 'success' | 'failure' | 'notifying'>
   thinking?: boolean
   allowBranch?: boolean
+  readOnly?: boolean
 }>(), {
   title: '',
   subtitle: '',
@@ -56,6 +57,7 @@ const props = withDefaults(defineProps<{
   agentStates: () => ({}),
   thinking: false,
   allowBranch: true,
+  readOnly: false,
 })
 
 const emit = defineEmits<{
@@ -370,8 +372,8 @@ defineExpose({ scrollToMessage, scrollToAnchor, scrollToBottom, isFollowingBotto
                 :aria-label="copiedMessageId === message.id ? '已复制' : copyFailedMessageId === message.id ? '复制失败' : '复制消息'"
                 @click="copyMessage(message)"
               ><AppIcon :name="copiedMessageId === message.id ? 'check' : copyFailedMessageId === message.id ? 'alert' : 'copy'" :size="13" /></button>
-              <button type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
-              <button v-if="allowBranch && message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
+              <button v-if="!readOnly" type="button" title="引用" aria-label="引用消息" @click="emit('quote', message)"><AppIcon name="quote" :size="13" /></button>
+              <button v-if="!readOnly && allowBranch && message.role === 'assistant'" type="button" title="从这里分支" aria-label="从这里分支" @click="emit('branch', message)"><AppIcon name="branch" :size="13" /></button>
             </div>
             </template>
           </div>

+ 0 - 4
src/client/stores/chat.ts

@@ -478,10 +478,6 @@ export const useChatStore = defineStore('chat', () => {
     // The runtime is attached lazily by send/interrupt/usage, or when a known
     // in-flight turn reconnects. REST history remains authoritative here.
     if (!state.historySynced) await loadHistory(state)
-    // Context usage comes from the realtime runtime rather than the REST
-    // history response.  Fetch it when opening an existing session so the
-    // composer does not stay at zero until a later event happens to include it.
-    void refreshContextUsage(state).catch(() => undefined)
     const refreshed = sessions.value.find(item => item.id === sessionId && item.profile === selectedProfile)
     syncSelectedModel(refreshed?.model, refreshed?.provider)
   }

+ 2 - 2
src/client/utils/interfaceMode.ts

@@ -2,8 +2,8 @@ export type InterfaceMode = 'chat' | 'bots'
 const KEY = 'hermes-yaoyao:interface-mode'
 
 export function savedInterfacePath(): '/chat' | '/conversations' {
-  try { return localStorage.getItem(KEY) === 'bots' ? '/conversations' : '/chat' }
-  catch { return '/chat' }
+  try { return localStorage.getItem(KEY) === 'chat' ? '/chat' : '/conversations' }
+  catch { return '/conversations' }
 }
 
 export function rememberInterfacePath(path: string): void {

+ 15 - 77
src/client/views/ChatView.vue

@@ -340,14 +340,7 @@ onMounted(async () => {
   document.addEventListener('keydown', handleSessionActionKey)
   window.addEventListener(MODEL_CATALOG_CHANGED_EVENT, handleModelCatalogChanged)
   await refreshSessions()
-  await Promise.allSettled([chat.loadModels(auth.activeProfile?.name), chat.connect()])
-  void chat.refreshActiveSessionModel().catch(() => undefined)
-  modelSyncTimer = window.setInterval(() => {
-    if (document.visibilityState === 'visible') void chat.refreshActiveSessionModel().catch(() => undefined)
-  }, 3_000)
   await revealSourceMessage()
-  const pendingFile = await consumeLibraryItemForComposer()
-  if (pendingFile) composer.value?.attachFiles([pendingFile])
 })
 
 onBeforeUnmount(() => {
@@ -369,30 +362,23 @@ watch(() => auth.activeProfile?.name, async (profile, previous) => {
   chat.switchProfile(profile)
   const requestedId = typeof route.params.sessionId === 'string' ? route.params.sessionId : ''
   if (requestedId && routeProfile() === profile) {
-    await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
+    await Promise.allSettled([chat.loadSessions(profile), chat.loadUnread(profile)])
     await chat.selectSession(requestedId, profile)
     return
   }
   await router.replace({ path: '/chat', query: { profile } })
-  await Promise.allSettled([chat.loadSessions(profile), chat.loadModels(profile), chat.loadUnread(profile), chat.connect()])
+  await Promise.allSettled([chat.loadSessions(profile), chat.loadUnread(profile)])
 })
 
 watch(() => chat.activeSessionId, async id => {
   const profile = chat.activeProfileName || auth.activeProfile?.name || ''
-  if (id) void chat.refreshActiveSessionModel().catch(() => undefined)
   if (!id || (route.params.sessionId === id && routeProfile() === profile)) return
   await router.replace(sessionLocation(id, profile))
 })
 </script>
 
 <template>
-  <WorkspaceView sidebar-title="对话" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview || outlineOpen" :inspector-close-label="outlineOpen ? '关闭会话大纲' : '关闭预览'" @close-inspector="closeInspector">
-    <template #sidebar-action>
-      <button class="sidebar-primary-action" type="button" title="新建聊天" aria-label="新建聊天" @click="createSession">
-        <YaoYaoSidebarIcon name="add" />
-        <span>新建聊天</span>
-      </button>
-    </template>
+  <WorkspaceView sidebar-title="历史记录" :sidebar-subtitle="`${sessions.length} 个会话`" :inspector-open="!!preview || outlineOpen" :inspector-close-label="outlineOpen ? '关闭会话大纲' : '关闭预览'" @close-inspector="closeInspector">
     <template #sidebar>
       <ResourceSidebar
         :items="sidebarItems"
@@ -403,8 +389,8 @@ watch(() => chat.activeSessionId, async id => {
         :has-more="chat.hasMoreSessions"
         :loading-more="chat.isLoadingMoreSessions"
         search-placeholder="搜索会话"
-        empty-title="还没有会话"
-        empty-description="新建会话后,从输入框开始聊天。"
+        empty-title="还没有历史会话"
+        empty-description="Hermes 会话会在这里以只读历史显示。"
         @load-more="chat.loadMoreSessions(auth.activeProfile?.name)"
         @select="chooseSession"
         @more="openSessionActions"
@@ -416,94 +402,46 @@ watch(() => chat.activeSessionId, async id => {
       <MessageTimeline
         ref="timeline"
         :messages="messages"
-        :title="activeSession?.title || '新会话'"
-        :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || chat.selectedModel?.name || '默认模型'}` : '选择会话或直接开始输入'"
+        :title="activeSession?.title || '历史记录'"
+        :subtitle="activeSession ? `${activeSession.profile || auth.activeProfile?.name || ''} · ${activeSession.model || '默认模型'}` : '选择左侧会话查看历史'"
         :loading="chat.activeRouteState?.isLoadingHistory || chat.isLoading"
         :loading-older="chat.activeRouteState?.isLoadingHistory"
         :has-older="chat.hasMoreBefore"
-        :connected="connected"
+        :connected="false"
         :synced="chat.historySynced"
         :show-tools="showThinking"
-        :thinking="chat.isSending || chat.isStreaming"
+        :thinking="false"
         :show-assistant-identity="false"
         :agent-avatars="agentAvatars"
-        :interaction="interaction"
+        :allow-branch="false"
+        read-only
         empty-logo
         transparent-header
-        :fork-source-title="forkSourceTitle"
         @load-older="chat.loadOlder"
-        @quote="quoted = $event"
-        @branch="branch"
         @preview="openAttachment"
         @preview-file="openLocalFile"
-        @approve="interaction && chat.respondToApproval(interaction.id, $event)"
-        @clarify="interaction && chat.respondToClarification(interaction.id, $event)"
       >
         <template #header-actions>
           <button v-if="activeSession" ref="headerSessionMenuButton" class="icon-button header-session-menu" type="button" title="会话操作" aria-label="会话操作" @click="openSessionActions(activeSession.id, $event)"><AppIcon name="dots" :size="18" /></button>
         </template>
       </MessageTimeline>
       <p v-if="chat.error" class="chat-error" role="alert"><AppIcon name="alert" :size="13" />{{ chat.error }}</p>
-      <ComposerShell
-        ref="composer"
-        mode="chat"
-        :draft-key="`${auth.user?.id || 'local'}:${auth.activeProfile?.name || 'default'}:${chat.activeSessionId || 'new'}`"
-        :disabled="chat.connectionState === 'failed'"
-        :streaming="chat.isStreaming"
-        :sending="chat.isSending"
-        :model-label="chat.selectedModel?.name || '选择模型'"
-        :fast-mode="chat.fastMode"
-        :fast-mode-disabled="!chat.activeSessionId"
-        :reasoning-effort="reasoningLabel"
-        :reasoning-value="chat.reasoningEffort || ''"
-        :reasoning-options="reasoningComposerOptions"
-        :context-used="contextUsed"
-        :context-limit="chat.contextUsage?.contextLimit || 262144"
-        :context-estimated="contextIsEstimated"
-        :queue-mode="queueMode || chat.isQueued"
-        :tool-trace-visible="showThinking"
-        :reference="reference"
-        :slash-commands="[
-          { id: 'fork', label: 'fork', detail: '从当前会话创建分支' },
-          { id: 'compact', label: 'compact', detail: '压缩当前会话上下文' },
-          { id: 'model', label: 'model', detail: '切换模型' },
-        ]"
-        @send="send"
-        @stop="chat.interrupt"
-        @model-click="modelDialog = true"
-        @fast-mode-toggle="toggleFastMode"
-        @reasoning-change="selectReasoning"
-        @settings-click="toggleShowThinking"
-        @queue-toggle="queueMode = !queueMode"
-        @clear-reference="quoted = null"
-      />
     </div>
 
     <template #inspector>
       <SessionOutline v-if="outlineOpen" :messages="messages" :has-older="chat.hasMoreBefore" @navigate="navigateOutline" />
-      <PreviewInspector v-else-if="preview" :item="preview" @close="preview = null" @add-to-composer="addPreviewToComposer" />
+      <PreviewInspector v-else-if="preview" :item="preview" :can-add-to-composer="false" @close="preview = null" />
     </template>
   </WorkspaceView>
 
   <FloatingResourceSearch section="chat" label="搜索会话" :items="sidebarItems" @select="chooseSession" />
-  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" @close="filePreview = null" @add-to-composer="addPreviewToComposer" @source="filePreview = null" />
-  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" @add="addMediaToComposer" />
-
-  <ModelChoiceDialog :open="modelDialog" :options="chat.models" :selected-id="selectedModelId" :busy="modelSwitching" @close="modelDialog = false" @select="selectModel" />
+  <PreviewModal v-if="filePreview" :item="filePreview" :items="conversationMediaItems" :can-add-to-composer="false" @close="filePreview = null" @source="filePreview = null" />
+  <ImagePreviewLightbox v-model="mediaPreviewIndex" :images="lightboxMedia" :can-add="false" />
 
   <Teleport to="body">
     <Transition name="session-menu">
       <section v-if="actionSessionId" class="session-actions" :style="actionMenuStyle" role="menu" aria-label="会话操作" @contextmenu.prevent>
-        <template v-if="renaming">
-          <label>会话名称<input v-model="renameValue" maxlength="120" autofocus @keydown.enter="saveRename" /></label>
-          <div><button class="quiet-button" type="button" @click="renaming = false">取消</button><button class="solid-button" type="button" @click="saveRename">保存</button></div>
-        </template>
-        <template v-else>
-          <button class="action-row" role="menuitem" type="button" @click="openSessionOutline"><AppIcon name="menu" :size="14" />会话大纲</button>
-          <button class="action-row" role="menuitem" type="button" @click="toggleSessionPinned"><AppIcon :name="actionSession?.pinned ? 'pin-off' : 'pin'" :size="14" />{{ actionSession?.pinned ? '取消置顶' : '置顶会话' }}</button>
-          <button class="action-row" role="menuitem" type="button" @click="renaming = true"><AppIcon name="edit" :size="14" />重命名</button>
-          <button class="action-row danger" role="menuitem" type="button" @click="deleteSession"><AppIcon name="trash" :size="14" />删除会话</button>
-        </template>
+        <button class="action-row" role="menuitem" type="button" @click="openSessionOutline"><AppIcon name="menu" :size="14" />会话大纲</button>
       </section>
     </Transition>
   </Teleport>

+ 3 - 30
src/client/views/FilesView.vue

@@ -3,7 +3,6 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import type { FileKind } from '@shared/types'
 import AppIcon from '@/components/common/AppIcon.vue'
-import YaoYaoSidebarIcon from '@/components/common/YaoYaoSidebarIcon.vue'
 import LibraryGrid from '@/components/library/LibraryGrid.vue'
 import PreviewModal from '@/components/library/PreviewModal.vue'
 import ImagePreviewLightbox from '@/components/library/ImagePreviewLightbox.vue'
@@ -11,7 +10,6 @@ import type { PreviewMedia } from '@/components/library/ImagePreviewLightbox.vue
 import type { LibraryFilterOption, UiLibraryItem } from '@/components/library/types'
 import ResourceSidebar from '@/components/app/ResourceSidebar.vue'
 import WorkspaceView from '@/components/workspace/WorkspaceView.vue'
-import { queueLibraryItemForComposer } from '@/components/workspace/pendingComposer'
 import { fileToUi, sessionSidebarItem } from '@/components/workspace/viewModels'
 import { useFilesStore } from '@/stores/files'
 import { useAuthStore } from '@/stores/auth'
@@ -56,16 +54,6 @@ function updateFilter(value: string) {
   void files.load(true)
 }
 
-async function addToComposer(item: UiLibraryItem) {
-  if (!queueLibraryItemForComposer(item)) return
-  await router.push('/chat')
-}
-
-async function addMediaToComposer(media: PreviewMedia) {
-  const item = items.value.find(candidate => (candidate.previewUrl || candidate.downloadUrl) === media.url)
-  if (item) await addToComposer(item)
-}
-
 async function openSource(item: UiLibraryItem) {
   if (item.sourceWorkspaceConversationId) {
     await router.push(`/conversations/${encodeURIComponent(item.sourceWorkspaceConversationId)}`)
@@ -99,14 +87,6 @@ async function selectSession(id: string) {
   await router.push({ path: `/chat/${encodeURIComponent(id)}`, query: profile ? { profile } : {} })
 }
 
-async function createChat() {
-  selected.value = null
-  mediaIndex.value = null
-  const profile = auth.activeProfile?.name || 'default'
-  const id = chat.createSession(profile)
-  await router.push({ path: `/chat/${encodeURIComponent(id)}`, query: { profile } })
-}
-
 onMounted(() => {
   files.profile = undefined
   void files.load(true)
@@ -128,12 +108,6 @@ watch(() => auth.activeProfile?.name, profile => {
 
 <template>
   <WorkspaceView sidebar-title="历史记录" :sidebar-subtitle="`${chat.sessions.length} 个会话`">
-    <template #sidebar-action>
-      <button type="button" title="新建聊天" aria-label="新建聊天" @click="createChat">
-        <YaoYaoSidebarIcon name="add" />
-        <span>新建聊天</span>
-      </button>
-    </template>
     <template #sidebar>
       <ResourceSidebar
         :items="historyItems"
@@ -143,7 +117,7 @@ watch(() => auth.activeProfile?.name, profile => {
         single-line
         search-placeholder="搜索会话"
         empty-title="还没有会话"
-        empty-description="创建会话后会显示在这里。"
+        empty-description="原生 Hermes 会话会在这里以只读历史显示。"
         @search="historySearch = $event"
         @select="selectSession"
       />
@@ -166,13 +140,12 @@ watch(() => auth.activeProfile?.name, profile => {
         empty-description="聊天中的图片、文档和附件会出现在这里。"
         @select="selectFile"
         @load-more="files.loadMore"
-        @add-to-composer="addToComposer"
         @source="openSource"
       />
     </section>
   </WorkspaceView>
-  <PreviewModal v-if="selected" :item="selected" :items="items" @close="selected = null" @add-to-composer="addToComposer" @source="openSource" />
-  <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" @add="addMediaToComposer" />
+  <PreviewModal v-if="selected" :item="selected" :items="items" :can-add-to-composer="false" @close="selected = null" @source="openSource" />
+  <ImagePreviewLightbox v-model="mediaIndex" :images="lightboxMedia" :can-add="false" />
 </template>
 
 <style scoped>

+ 8 - 1
src/server/realtimeApi.ts

@@ -146,7 +146,7 @@ export class RealtimeAPI {
       ctx.set('Cache-Control', 'no-store')
       const path = match![2]!
       if (ctx.method === 'GET' && path === '/capabilities') {
-        ctx.body = { protocolVersion: 1, channels: ['chat'], brokerEpoch: this.broker.epoch,
+        ctx.body = { protocolVersion: 1, channels: [], brokerEpoch: this.broker.epoch,
           ...(device ? {} : { csrfToken: this.csrf.issue(ctx) }) }
         return
       }
@@ -154,6 +154,13 @@ export class RealtimeAPI {
       if (ctx.method === 'POST' && path === '/channels') {
         const body = await this.body(ctx)
         if (body.channel !== 'chat' && body.channel !== 'groups') throw new HttpError(400, 'Invalid channel', 'invalid_channel')
+        if (body.channel === 'chat') {
+          throw new HttpError(
+            410,
+            '原生 Hermes 会话仅供历史查看;请在聊天中开始或继续对话',
+            'native_sessions_read_only',
+          )
+        }
         const c = await this.broker.create(principal, body.channel, body.channel === 'groups'
           ? { epoch: canonicalEpoch(body.epoch), cursor: groupCursor(body.cursor) } : undefined)
         ctx.status = 201; ctx.body = { id: c.id, brokerEpoch: this.broker.epoch }; return

+ 23 - 15
src/server/routes.ts

@@ -311,6 +311,21 @@ function pairedProxyScope(path: string, method: string): NodeScope {
   return 'sessions.execute'
 }
 
+function isNativeSessionMutation(path: string, method: string): boolean {
+  if (['GET', 'HEAD'].includes(method)) return false
+  return path === '/sessions'
+    || path.startsWith('/sessions/')
+    || /^\/profiles\/[^/]+\/sessions(?:\/|$)/.test(path)
+}
+
+function nativeSessionsReadOnly(): never {
+  throw new HttpError(
+    410,
+    '原生 Hermes 会话仅供历史查看;请在聊天中开始或继续对话',
+    'native_sessions_read_only',
+  )
+}
+
 function pairedProxyPath(rawPath: string): string {
   const path = rawPath.startsWith('/') ? rawPath : `/${rawPath}`
   if (path.includes('\\') || path.includes('\u0000') || path.length > 2_048) {
@@ -1291,6 +1306,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       ? ctx.params.nodePath.join('/')
       : ctx.params.nodePath
     const path = pairedProxyPath(rawPath)
+    if (isNativeSessionMutation(path.slice('/api'.length), ctx.method)) {
+      nativeSessionsReadOnly()
+    }
     const scope = pairedProxyScope(path.slice('/api'.length), ctx.method)
     const token = bearerToken(ctx.get('authorization'))
     const cookieHeader = dependencies.pairings.authorize(
@@ -1800,23 +1818,10 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     })
   })
   router.patch('/api/app/sessions/:sessionID', async (ctx) => {
-    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
-    const search = searchFrom(ctx, ['profile'])
-    const request = body(ctx)
-    const profile = search.get('profile')
-    if (profile) request.profile = profile
-    await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
-      method: 'PATCH',
-      search,
-      requestBody: request,
-    })
+    nativeSessionsReadOnly()
   })
   router.delete('/api/app/sessions/:sessionID', async (ctx) => {
-    const id = safeIdentifier(ctx.params.sessionID, 'session ID')
-    await proxy(ctx, dependencies.upstream, `/api/sessions/${encodeURIComponent(id)}`, {
-      method: 'DELETE',
-      search: searchFrom(ctx, ['profile']),
-    })
+    nativeSessionsReadOnly()
   })
 
   router.all('/api/*gatewayPath', async (ctx) => {
@@ -1828,6 +1833,9 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       ? ctx.params.gatewayPath.join('/')
       : ctx.params.gatewayPath
     const upstreamPath = pairedProxyPath(raw)
+    if (isNativeSessionMutation(upstreamPath.slice('/api'.length), ctx.method)) {
+      nativeSessionsReadOnly()
+    }
     const response = await dependencies.upstreamSession.request(upstreamPath, {
       method: ctx.method,
       search: new URLSearchParams(ctx.querystring),

+ 4 - 4
tests/client/interfaceMode.test.ts

@@ -4,8 +4,8 @@ import { rememberInterfacePath, savedInterfacePath } from '@/utils/interfaceMode
 beforeEach(() => localStorage.clear())
 
 describe('last interface mode', () => {
-  it('restores Bot mode and chat mode after explicit navigation', () => {
-    expect(savedInterfacePath()).toBe('/chat')
+  it('opens application-owned chats by default and remembers an explicit history choice', () => {
+    expect(savedInterfacePath()).toBe('/conversations')
     rememberInterfacePath('/conversations/existing-group')
     expect(savedInterfacePath()).toBe('/conversations')
     rememberInterfacePath('/files')
@@ -16,8 +16,8 @@ describe('last interface mode', () => {
 
   it('does not mistake unrelated paths or invalid storage for a mode', () => {
     localStorage.setItem('hermes-yaoyao:interface-mode', 'invalid')
-    expect(savedInterfacePath()).toBe('/chat')
+    expect(savedInterfacePath()).toBe('/conversations')
     rememberInterfacePath('/conversations-other')
-    expect(savedInterfacePath()).toBe('/chat')
+    expect(savedInterfacePath()).toBe('/conversations')
   })
 })

+ 9 - 5
tests/server/readCacheRoutes.test.ts

@@ -45,12 +45,16 @@ describe('authenticated read cache routes', () => {
     const after = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(200)
     expect(after.body.sessions[0].title).toBe('external-change')
     expect(sessionReads).toBe(3)
-    await agent.patch('/api/sessions/one').set('Host', '127.0.0.1:8800').send({ title: 'renamed' }).expect(200)
-    const renamed = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(200)
-    expect(renamed.body.sessions[0].title).toBe('renamed')
-    expect(sessionReads).toBe(4)
+    await agent.patch('/api/sessions/one').set('Host', '127.0.0.1:8800')
+      .send({ title: 'renamed' }).expect(410, {
+        error: '原生 Hermes 会话仅供历史查看;请在聊天中开始或继续对话',
+        code: 'native_sessions_read_only',
+      })
+    const unchanged = await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(200)
+    expect(unchanged.body.sessions[0].title).toBe('external-change')
+    expect(sessionReads).toBe(3)
     await agent.post('/auth/logout').set('Host', '127.0.0.1:8800').send({}).expect(200)
     await agent.get('/api/sessions').set('Host', '127.0.0.1:8800').expect(401)
-    expect(sessionReads).toBe(4)
+    expect(sessionReads).toBe(3)
   })
 })

+ 71 - 159
tests/server/realtimeApi.test.ts

@@ -1,171 +1,83 @@
 // @vitest-environment node
-import { createServer } from 'node:http'
-import { once } from 'node:events'
-import { mkdtempSync } from 'node:fs'
+import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import type { AddressInfo } from 'node:net'
-import { WebSocketServer, type WebSocket } from 'ws'
+import request from 'supertest'
 import { afterEach, describe, expect, it } from 'vitest'
-import { createAuthenticatedApplication } from './authenticatedApplication.js'
-import { createNodeServer } from '../../src/server/app.js'
-import { SSEParser } from '../../src/shared/sse.js'
+import { createApplication, type ApplicationRuntime } from '../../src/server/app.js'
 import type { ServerConfig } from '../../src/server/config.js'
 
-const closers: Array<() => Promise<void>> = []
-afterEach(async () => { for (const close of closers.splice(0).reverse()) await close() })
+const roots: string[] = []
+const runtimes: ApplicationRuntime[] = []
+afterEach(() => {
+  for (const runtime of runtimes.splice(0)) runtime.close()
+  for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
+})
 
-async function setup(local = false) {
-  const localToken = 'fixture_native_loopback_token_123456'
-  const server = createServer((req, res) => {
-    if (req.url === '/api/status') { res.end(JSON.stringify({ auth_required: false })); return }
-    if (req.url === '/') { res.end(`<script>window.__HERMES_SESSION_TOKEN__=${JSON.stringify(localToken)};</script>`); return }
-    if (req.headers['x-hermes-session-token'] !== localToken) { res.writeHead(401); res.end('{}'); return }
-    res.end(JSON.stringify({ profiles: [] }))
-  })
-  const wss = new WebSocketServer({ server, verifyClient: ({ req }) => {
-    const url = new URL(req.url!, 'http://127.0.0.1')
-    return local ? url.searchParams.get('token') === localToken && !url.searchParams.has('ticket')
-      : url.searchParams.get('ticket') === 'upstream-secret'
-  } })
-  const peers = new Set<WebSocket>(), commands: any[] = []
-  let connectionCount = 0, sequence = 0
-  wss.on('connection', peer => {
-    connectionCount++; peers.add(peer); peer.on('close', () => peers.delete(peer))
-    peer.send(JSON.stringify({ method: 'event', params: { type: 'gateway.ready', payload: { replay_epoch: 'fixture' } } }))
-    peer.on('message', raw => {
-      const command = JSON.parse(raw.toString()); commands.push(command)
-      peer.send(JSON.stringify({ id: command.id, result: command.method === 'session.resume'
-        ? { session_id: 'runtime', stored_session_id: 'stored', running: false } : { status: 'streaming' } }))
-    })
-  })
-  server.listen(0, '127.0.0.1'); await once(server, 'listening')
-  closers.push(async () => { for (const c of wss.clients) c.terminate(); wss.close(); await new Promise<void>(r => server.close(() => r())) })
-  const home = mkdtempSync(join(tmpdir(), 'yaoyao-http-sse-api-'))
-  const config: ServerConfig = { host: '127.0.0.1', port: 8800,
-    upstream: new URL(`http://127.0.0.1:${(server.address() as AddressInfo).port}`), allowedHosts: new Set(),
-    home, mediaRoot: home, attachmentsRoot: home, imagesRoot: home, mediaOwner: 'test',
-    allowInsecureLan: false, insecureLan: false, production: false }
-  const runtime = createAuthenticatedApplication({ config, fetchImpl: local ? undefined : (async input => {
-    const path = new URL(String(input)).pathname
-    if (path === '/api/auth/ws-ticket') return Response.json({ ticket: 'upstream-secret' })
-    if (path === '/api/status') return Response.json({ auth_required: true })
-    return Response.json({ user_id: 'service', profiles: [] })
-  }) as typeof fetch })
-  const node = createNodeServer(runtime)
-  node.server.listen(0, '127.0.0.1'); await once(node.server, 'listening')
-  config.port = (node.server.address() as AddressInfo).port
-  closers.push(() => node.close())
-  const origin = `http://127.0.0.1:${config.port}`
-  const caps = await fetch(`${origin}/api/realtime/capabilities`)
-  const token = await caps.json() as { csrfToken: string }
-  const cookie = caps.headers.getSetCookie().map(c => c.split(';')[0]).join('; ')
-  const headers = { Cookie: cookie, Origin: origin, 'X-CSRF-Token': token.csrfToken, 'Content-Type': 'application/json' }
-  const create = async () => {
-    const response = await fetch(`${origin}/api/realtime/channels`, { method: 'POST', headers, body: '{"channel":"chat"}' })
-    expect(response.status).toBe(201)
-    return (await response.json() as { id: string }).id
-  }
-  const command = async (channel: string, id: string, method: string, params: object) => {
-    const response = await fetch(`${origin}/api/realtime/channels/${channel}/commands`, {
-      method: 'POST', headers: { ...headers, 'Idempotency-Key': id }, body: JSON.stringify({ method, params }),
+describe('native Hermes sessions are read-only on 8800', () => {
+  it('does not advertise realtime chat and rejects all native session writes locally', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-native-history-'))
+    roots.push(home)
+    let upstreamCalls = 0
+    const config: ServerConfig = {
+      host: '127.0.0.1', port: 8800, upstream: new URL('http://127.0.0.1:9119'),
+      allowedHosts: new Set(), home, mediaRoot: home, attachmentsRoot: home,
+      imagesRoot: home, mediaOwner: 'test', allowInsecureLan: false,
+      insecureLan: false, production: false,
+    }
+    const runtime = createApplication({
+      config,
+      fetchImpl: (async (input, init) => {
+        upstreamCalls += 1
+        const path = new URL(String(input)).pathname
+        if (path === '/api/status') return Response.json({ auth_required: true })
+        if (path === '/api/auth/me') return Response.json({ user_id: 'service' })
+        if (path === '/api/auth/providers') return Response.json({ providers: [{ name: 'basic', supports_password: true }] })
+        if (path === '/auth/password-login') return new Response(JSON.stringify({ ok: true }), {
+          headers: { 'content-type': 'application/json', 'set-cookie': 'hermes_service=session; Path=/; HttpOnly' },
+        })
+        if (path === '/api/profiles') return Response.json({ profiles: [{ name: 'default' }] })
+        return Response.json({ method: init?.method ?? 'GET' })
+      }) as typeof fetch,
     })
-    return { response, receipt: await response.json() as any }
-  }
-  const emit = (text: string) => {
-    const payload = { text, emitted: performance.now() }
-    for (const peer of peers) peer.send(JSON.stringify({ method: 'event', params: { type: 'message.delta', session_id: 'runtime', seq: ++sequence, payload } }))
-  }
-  return { origin, headers, create, command, emit, commands, config, count: () => connectionCount }
-}
+    runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const origin = 'http://127.0.0.1:8800'
+    const bootstrap = await agent.get('/api/app/bootstrap').set('Host', '127.0.0.1:8800').expect(200)
+    const login = await agent.post('/api/app/login').set('Host', '127.0.0.1:8800')
+      .set('Origin', origin).set('X-CSRF-Token', bootstrap.body.csrfToken)
+      .send({ username: 'admin', password: 'admin' }).expect(200)
+    const credentials = await agent.put('/api/app/account/credentials')
+      .set('Host', '127.0.0.1:8800').set('Origin', origin)
+      .set('X-CSRF-Token', login.body.csrfToken)
+      .send({ username: 'owner', currentPassword: 'admin', newPassword: 'fixture-password' }).expect(200)
+    const csrf = credentials.body.csrfToken
 
-describe('HTTP/SSE realtime API', () => {
-  it('connects to native loopback Hermes without an account for REST and realtime', async () => {
-    const f = await setup(true)
-    const c = await f.create()
-    const result = await f.command(c, 'local-resume', 'session.resume', { session_id: 'stored', profile: 'default' })
-    expect(result.receipt).toMatchObject({ state: 'confirmed', response: { result: { session_id: 'runtime' } } })
-    const invalid = await fetch(`${f.origin}/api/realtime/channels`, { method: 'POST', headers: { ...f.headers, 'X-CSRF-Token': 'bad' }, body: '{"channel":"chat"}' })
-    expect(invalid.status).toBe(403)
-    expect(f.count()).toBe(1)
-  })
-  it('enforces CSRF and returns upstream-confirmed receipts', async () => {
-    const f = await setup()
-    const invalid = await fetch(`${f.origin}/api/realtime/channels`, { method: 'POST', headers: { ...f.headers, 'X-CSRF-Token': 'bad' }, body: '{"channel":"chat"}' })
-    expect(invalid.status).toBe(403)
-    const c = await f.create()
-    const result = await f.command(c, 'resume', 'session.resume', { session_id: 'stored', profile: 'default' })
-    expect(result.receipt).toMatchObject({ state: 'confirmed', response: { result: { session_id: 'runtime' } } })
-    const forbidden = await f.command(c, 'forbidden', 'terminal.resize', { session_id: 'runtime' })
-    expect(forbidden.response.status).toBe(403)
-    expect(f.commands).toHaveLength(1)
-  })
-  it('allows existing attachment sizes only on the dedicated command endpoint', async () => {
-    const f = await setup(), c = await f.create()
-    await f.command(c, 'resume', 'session.resume', { session_id: 'stored', profile: 'default' })
-    const attached = await f.command(c, 'attachment', 'image.attach_bytes', { session_id: 'runtime', content_base64: 'A'.repeat(3 * 1024 * 1024), filename: 'fixture.png' })
-    expect(attached.response.status).toBe(200)
-    expect(attached.receipt.state).toBe('confirmed')
-    const other = await fetch(`${f.origin}/api/app/sessions`, { method: 'POST', headers: f.headers, body: JSON.stringify({ data: 'A'.repeat(3 * 1024 * 1024) }) })
-    expect(other.status).toBe(413)
-  })
+    const capabilities = await agent.get('/api/realtime/capabilities').set('Host', '127.0.0.1:8800').expect(200)
+    expect(capabilities.body.channels).toEqual([])
+    await agent.post('/api/realtime/channels').set('Host', '127.0.0.1:8800')
+      .set('Origin', origin).set('X-CSRF-Token', csrf).send({ channel: 'chat' })
+      .expect(410, { error: '原生 Hermes 会话仅供历史查看;请在聊天中开始或继续对话', code: 'native_sessions_read_only' })
 
-  it('shares Web/iOS subscriptions, replays after detach, and measures stream latency', async () => {
-    const f = await setup(), a = await f.create(), b = await f.create()
-    await f.command(a, 'resume-a', 'session.resume', { session_id: 'stored', profile: 'default' })
-    await f.command(b, 'resume-b', 'session.resume', { session_id: 'stored', profile: 'default' })
-    const controller = new AbortController()
-    const response = await fetch(`${f.origin}/api/realtime/channels/${a}/events`, { headers: f.headers, signal: controller.signal })
-    expect(response.headers.get('content-type')).toContain('text/event-stream')
-    expect(response.headers.get('x-accel-buffering')).toBe('no')
-    const reader = response.body!.getReader(), decoder = new TextDecoder(), parser = new SSEParser()
-    const initial = []
-    while (!initial.some(x => x.event === 'frame')) initial.push(...parser.feed(decoder.decode((await reader.read()).value, { stream: true })))
-    const cursor = initial.find(x => x.event === 'frame')!.id!
-    controller.abort(); await reader.cancel().catch(() => {})
-    f.emit('while-offline')
-    await new Promise(r => setTimeout(r, 30))
-    const resumedController = new AbortController()
-    const resumed = await fetch(`${f.origin}/api/realtime/channels/${a}/events`, { headers: { ...f.headers, 'Last-Event-ID': cursor }, signal: resumedController.signal })
-    const resumedReader = resumed.body!.getReader(), resumedParser = new SSEParser(), resumedDecoder = new TextDecoder()
-    const entries = []
-    while (!entries.some(x => x.event === 'frame')) entries.push(...resumedParser.feed(resumedDecoder.decode((await resumedReader.read()).value, { stream: true })))
-    expect(entries.filter(e => e.event === 'frame').map(e => JSON.parse(e.data).params.payload.text)).toEqual(['while-offline'])
-    expect(f.count()).toBe(1)
-    const latencies: number[] = []
-    const before = process.memoryUsage().rss, cpu = process.cpuUsage()
-    const consume = (async () => {
-      while (latencies.length < 200) {
-        const chunk = await resumedReader.read()
-        for (const e of resumedParser.feed(resumedDecoder.decode(chunk.value, { stream: true }))) {
-          if (e.event === 'frame') latencies.push(performance.now() - JSON.parse(e.data).params.payload.emitted)
-        }
-      }
-    })()
-    for (let i = 0; i < 200; i++) { f.emit(`token-${i}`); await new Promise<void>(r => setImmediate(r)) }
-    await consume
-    latencies.sort((x, y) => x - y)
-    console.info('SSE fixture performance', JSON.stringify({ samples: latencies.length, p95Ms: latencies[189], p99Ms: latencies[197], rssDelta: process.memoryUsage().rss - before, cpuMicros: process.cpuUsage(cpu) }))
-    expect(latencies).toHaveLength(200)
-    resumedController.abort(); await resumedReader.cancel().catch(() => {})
+    const beforeWrites = upstreamCalls
+    for (const path of ['/api/app/sessions/session-1', '/api/sessions/session-1']) {
+      const response = await agent.patch(path).set('Host', '127.0.0.1:8800')
+        .set('Origin', origin).set('X-CSRF-Token', csrf).send({ title: '不能修改' }).expect(410)
+      expect(response.body.code).toBe('native_sessions_read_only')
+    }
+    const pairing = runtime.pairings.create('hermes_device=session')
+    const paired = runtime.pairings.claim({
+      pairingID: pairing.id,
+      secret: pairing.secret,
+      deviceName: 'history-only fixture',
+    })
+    const pairedWrite = await request(runtime.app.callback())
+      .patch(`/node/${paired.device.id}/api/sessions/session-1`)
+      .set('Host', '127.0.0.1:8800')
+      .set('Authorization', `Bearer ${paired.token}`)
+      .send({ title: '不能修改' })
+      .expect(410)
+    expect(pairedWrite.body.code).toBe('native_sessions_read_only')
+    expect(upstreamCalls).toBe(beforeWrites)
   })
-  it('keeps an admitted run connected after the only SSE client is offline for over twenty seconds', async () => {
-    const f = await setup(), c = await f.create()
-    await f.command(c, 'open', 'session.resume', { session_id: 'stored', profile: 'default' })
-    await f.command(c, 'send', 'prompt.submit', { session_id: 'runtime', text: 'long-running fixture' })
-    const controller = new AbortController()
-    const response = await fetch(`${f.origin}/api/realtime/channels/${c}/events`, { headers: f.headers, signal: controller.signal })
-    const reader = response.body!.getReader()
-    await reader.read(); controller.abort(); await reader.cancel().catch(() => {})
-    await new Promise(r => setTimeout(r, 21_000))
-    expect(f.count()).toBe(1)
-    f.emit('finished while offline')
-    const reconnect = new AbortController()
-    const again = await fetch(`${f.origin}/api/realtime/channels/${c}/events`, { headers: f.headers, signal: reconnect.signal })
-    const input = again.body!.getReader(), decoder = new TextDecoder()
-    let text = ''
-    while (!text.includes('finished while offline')) text += decoder.decode((await input.read()).value, { stream: true })
-    expect(text).toContain('finished while offline')
-    reconnect.abort(); await input.cancel().catch(() => {})
-  }, 30_000)
 })

+ 4 - 22
tests/server/realtimeMigration.test.ts

@@ -147,32 +147,14 @@ describe('HTTP+SSE migration', () => {
     })
     expect(retired.status).toBe(410)
     const capabilities = await fetch(`${claim.serverUrl}/api/realtime/capabilities`, { headers: { Authorization: authorization } })
-    expect(await capabilities.json()).toMatchObject({ protocolVersion: 1 })
+    expect(await capabilities.json()).toMatchObject({ protocolVersion: 1, channels: [] })
     const opened = await fetch(`${claim.serverUrl}/api/realtime/channels`, {
       method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json' },
       body: JSON.stringify({ channel: 'chat' }),
     })
-    expect(opened.status).toBe(201)
-    const channel = await opened.json() as { id: string }
-    const stream = await fetch(`${claim.serverUrl}/api/realtime/channels/${channel.id}/events`, {
-      headers: { Authorization: authorization, Accept: 'text/event-stream' },
-    })
-    expect(stream.headers.get('content-type')).toContain('text/event-stream')
-    await stream.body!.cancel()
-    const send = async (id: string, method: string, params: object) => {
-      const response = await fetch(`${claim.serverUrl}/api/realtime/channels/${channel.id}/commands`, {
-        method: 'POST', headers: { Authorization: authorization, 'Content-Type': 'application/json', 'Idempotency-Key': id },
-        body: JSON.stringify({ method, params }),
-      })
-      expect(response.status).toBe(200)
-      expect(await response.json()).toMatchObject({ state: 'confirmed' })
-    }
-    await send('profiles', 'profiles.list', { include_sessions: true })
-    await send('profile-meta', 'profiles.configure', { name: 'default', ui_meta: { 'hermes-bots': { chat: 'stored-session' } } })
-    await send('profile-avatar', 'profiles.set_asset', { name: 'default', asset: 'avatar', data: 'data:image/png;base64,AA==' })
-    await send('hidden-bot', 'session.create', { profile: 'default', title: 'Bot Chat', source: 'ios_bot_group', hidden: true })
-    expect(pairedFrames.map(frame => frame.method)).toEqual(['profiles.list', 'profiles.configure', 'profiles.set_asset', 'session.create'])
-    expect(pairedFrames[3]).toMatchObject({ params: { profile: 'default', source: 'ios_bot_group', hidden: true } })
+    expect(opened.status).toBe(410)
+    expect(await opened.json()).toMatchObject({ code: 'native_sessions_read_only' })
+    expect(pairedFrames).toEqual([])
     const revoke = await fetch(`${origin}/api/pair/v1/devices/${claim.deviceId}`, {
       method: 'DELETE', headers: { Authorization: authorization },
     })