Parcourir la source

Reduce chat startup payloads and load large tool results on demand

Samien il y a 1 mois
Parent
commit
629a82e3a4

+ 25 - 4
src/client/components/messages/ToolTrace.vue

@@ -1,17 +1,36 @@
 <script setup lang="ts">
 <script setup lang="ts">
-import { computed, ref, watch } from 'vue'
+import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
+import { apiRequest } from '@/api/client'
 import type { UiToolCall } from './types'
 import type { UiToolCall } from './types'
 
 
 const props = withDefaults(defineProps<{ tool: UiToolCall; expanded?: boolean }>(), { expanded: false })
 const props = withDefaults(defineProps<{ tool: UiToolCall; expanded?: boolean }>(), { expanded: false })
 const open = ref(props.expanded || props.tool.status === 'error')
 const open = ref(props.expanded || props.tool.status === 'error')
+const loaded = shallowRef<{ input?: unknown; output?: unknown }>()
+const loading = ref(false), error = ref('')
+let request: AbortController | undefined
+async function loadDetails() {
+  const url = props.tool.detailsUrl
+  if (!open.value || !url || loaded.value || loading.value) return
+  const controller = new AbortController()
+  request = controller; loading.value = true; error.value = ''
+  try {
+    const value = await apiRequest<{ input?: unknown; output?: unknown }>(url, { signal: controller.signal })
+    if (request === controller && !controller.signal.aborted) loaded.value = value
+  } catch (cause) {
+    if (request === controller && !controller.signal.aborted) error.value = cause instanceof Error ? cause.message : '工具详情加载失败'
+  } finally { if (request === controller) loading.value = false }
+}
+watch(() => props.tool.detailsUrl, () => { request?.abort();request = undefined;loaded.value = undefined;loading.value = false;error.value = '';void loadDetails() })
+watch(open, () => { void loadDetails() }, { immediate: true })
+onBeforeUnmount(() => request?.abort())
 const statusLabel = computed(() => ({ running: '运行中', success: '完成', error: '失败', pending: '等待', interrupted: '已结束,结果未确认' })[props.tool.status])
 const statusLabel = computed(() => ({ running: '运行中', success: '完成', error: '失败', pending: '等待', interrupted: '已结束,结果未确认' })[props.tool.status])
 function detail(value: unknown): string {
 function detail(value: unknown): string {
   if (value === undefined || value === null || value === '') return ''
   if (value === undefined || value === null || value === '') return ''
   if (typeof value === 'string') return value
   if (typeof value === 'string') return value
   try { return JSON.stringify(value, null, 2) } catch { return String(value ?? '') }
   try { return JSON.stringify(value, null, 2) } catch { return String(value ?? '') }
 }
 }
-const inputDetail = computed(() => detail(props.tool.input))
-const outputDetail = computed(() => detail(props.tool.output))
+const inputDetail = computed(() => detail(loaded.value ? loaded.value.input : props.tool.input))
+const outputDetail = computed(() => detail(loaded.value ? loaded.value.output : props.tool.output))
 watch(() => props.expanded, value => { if (value) open.value = true })
 watch(() => props.expanded, value => { if (value) open.value = true })
 </script>
 </script>
 
 
@@ -22,7 +41,9 @@ watch(() => props.expanded, value => { if (value) open.value = true })
       <small v-if="tool.status === 'error' || tool.status === 'interrupted'">{{ statusLabel }}</small>
       <small v-if="tool.status === 'error' || tool.status === 'interrupted'">{{ statusLabel }}</small>
       <span class="tool-trace__caret" :class="{ open }">›</span>
       <span class="tool-trace__caret" :class="{ open }">›</span>
     </button>
     </button>
-    <div v-if="open && (inputDetail || outputDetail)" class="tool-trace__details">
+    <div v-if="open && (inputDetail || outputDetail || tool.detailsUrl)" class="tool-trace__details">
+      <small v-if="loading" role="status">正在加载工具详情…</small>
+      <button v-if="error" type="button" @click="loadDetails">{{ error }},点击重试</button>
       <section v-if="inputDetail"><small>输入</small><pre>{{ inputDetail }}</pre></section>
       <section v-if="inputDetail"><small>输入</small><pre>{{ inputDetail }}</pre></section>
       <section v-if="outputDetail"><small>输出</small><pre>{{ outputDetail }}</pre></section>
       <section v-if="outputDetail"><small>输出</small><pre>{{ outputDetail }}</pre></section>
     </div>
     </div>

+ 1 - 1
src/client/components/messages/TurnTrace.vue

@@ -25,7 +25,7 @@ const statusLabel = computed(() => props.group.status === 'running' ? '进行中
           <header><AppIcon name="brain" :size="12" />思考过程 · {{ entry.content.length }} 字</header>
           <header><AppIcon name="brain" :size="12" />思考过程 · {{ entry.content.length }} 字</header>
           <MarkdownContent process-content :content="entry.content" :streaming="group.status === 'running'" :stream-interval-ms="streamIntervalMs" />
           <MarkdownContent process-content :content="entry.content" :streaming="group.status === 'running'" :stream-interval-ms="streamIntervalMs" />
         </section>
         </section>
-        <ToolTrace v-else class="turn-trace__item turn-trace__tool" :tool="entry.tool" expanded />
+        <ToolTrace v-else class="turn-trace__item turn-trace__tool" :tool="entry.tool" :expanded="!entry.tool.detailsUrl" />
       </template>
       </template>
     </div>
     </div>
   </SystemMessageNotice>
   </SystemMessageNotice>

+ 1 - 0
src/client/components/messages/types.ts

@@ -4,6 +4,7 @@ export type UiToolCall = {
   status: 'running' | 'success' | 'error' | 'pending' | 'interrupted'
   status: 'running' | 'success' | 'error' | 'pending' | 'interrupted'
   input?: unknown
   input?: unknown
   output?: unknown
   output?: unknown
+  detailsUrl?: string
   durationMs?: number
   durationMs?: number
 }
 }
 
 

+ 3 - 1
src/client/components/workspace/transcriptStore.ts

@@ -20,6 +20,7 @@ export interface WorkspaceSnapshot {
   agents: WorkspaceAgent[]
   agents: WorkspaceAgent[]
   conversations: WorkspaceConversation[]
   conversations: WorkspaceConversation[]
   details: WorkspaceDetail[]
   details: WorkspaceDetail[]
+  partialDetails?: boolean
   cursor: number
   cursor: number
 }
 }
 export const transcriptKey = (id: string, taskId?: string | null) => `${id}:${taskId ?? ''}`
 export const transcriptKey = (id: string, taskId?: string | null) => `${id}:${taskId ?? ''}`
@@ -141,8 +142,9 @@ export class WorkspaceTranscriptStore {
     for (const [key, detail] of previous) {
     for (const [key, detail] of previous) {
       const conversation = snapshot.conversations.find(c => c.id === detail.conversation.id)
       const conversation = snapshot.conversations.find(c => c.id === detail.conversation.id)
       const current = snapshot.details.find(d => d.conversation.id === detail.conversation.id)
       const current = snapshot.details.find(d => d.conversation.id === detail.conversation.id)
-      if (!this.details.has(key) && conversation && (conversation.archived || current?.tasks?.some(t => t.id === detail.task?.id))) {
+      if (!this.details.has(key) && conversation && (snapshot.partialDetails || conversation.archived || current?.tasks?.some(t => t.id === detail.task?.id))) {
         this.details.set(key, { ...detail, conversation }); this.staleDetails.add(key)
         this.details.set(key, { ...detail, conversation }); this.staleDetails.add(key)
+        for (const message of detail.messages) this.messageReconciler.remember(message)
       }
       }
     }
     }
     for (const event of concurrent) this.accept(event)
     for (const event of concurrent) this.accept(event)

+ 1 - 0
src/client/components/workspace/viewModels.ts

@@ -423,6 +423,7 @@ export function workspaceMessagesToUi(messages: import('@shared/workspace').Work
         id: String(tool.id || index), name: String(tool.name || tool.tool_name || '工具'),
         id: String(tool.id || index), name: String(tool.name || tool.tool_name || '工具'),
         status: status === 'completed' ? 'success' : status === 'failed' ? 'error' : status,
         status: status === 'completed' ? 'success' : status === 'failed' ? 'error' : status,
         input: tool.arguments ?? tool.args ?? tool.input, output: tool.error ?? tool.result ?? tool.output,
         input: tool.arguments ?? tool.args ?? tool.input, output: tool.error ?? tool.result ?? tool.output,
+        detailsUrl: typeof tool.detailsUrl === 'string' ? tool.detailsUrl : undefined,
       }
       }
     }),
     }),
   }))
   }))

+ 7 - 4
src/client/views/ConversationsView.vue

@@ -341,7 +341,7 @@ async function load(id = selected.value, append = false, signal?: AbortSignal) {
     activeTask.value = null;tasks.value = [];assignments.value = [];quoted.value = null;older.value = false
     activeTask.value = null;tasks.value = [];assignments.value = [];quoted.value = null;older.value = false
   }
   }
   try {
   try {
-    const incoming = cached ?? await apiRequest<WorkspaceDetail>(`/api/app/conversations/${id}?limit=50${requestedTask ? `&taskId=${encodeURIComponent(requestedTask)}` : ''}`, { signal })
+    const incoming = cached ?? await apiRequest<WorkspaceDetail>(`/api/app/conversations/${id}?limit=50&toolDetails=lazy${requestedTask ? `&taskId=${encodeURIComponent(requestedTask)}` : ''}`, { signal })
     if (!current()) return
     if (!current()) return
     const r = cached ?? transcriptStore.finishRead(readToken, incoming)
     const r = cached ?? transcriptStore.finishRead(readToken, incoming)
     if (cached && transcriptStore.staleDetails.has(transcriptKey(id, cached.task?.id))) {
     if (cached && transcriptStore.staleDetails.has(transcriptKey(id, cached.task?.id))) {
@@ -525,7 +525,10 @@ async function hydrateWorkspace(signal: AbortSignal) {
   if (capabilities.csrfToken) setApiCsrfToken(capabilities.csrfToken)
   if (capabilities.csrfToken) setApiCsrfToken(capabilities.csrfToken)
   const readToken = transcriptStore.beginRead()
   const readToken = transcriptStore.beginRead()
   try {
   try {
-  const snapshot = await apiRequest<WorkspaceSnapshot & { serverIdentity?: ServerIdentity; projects?: WorkspaceProject[] }>('/api/app/workspace/snapshot', { signal })
+  const query = new URLSearchParams({ details: 'selected', toolDetails: 'lazy' })
+  if (selected.value) query.set('conversationId', selected.value)
+  if (selectedTask.value) query.set('taskId', selectedTask.value)
+  const snapshot = await apiRequest<WorkspaceSnapshot & { serverIdentity?: ServerIdentity; projects?: WorkspaceProject[] }>(`/api/app/workspace/snapshot?${query}`, { signal })
   if (signal.aborted || disposed || accountEpoch !== epoch || auth.user?.id !== owner) return
   if (signal.aborted || disposed || accountEpoch !== epoch || auth.user?.id !== owner) return
   transcriptStore.hydrate(snapshot)
   transcriptStore.hydrate(snapshot)
   agents.value = snapshot.agents; conversations.value = transcriptStore.conversations; cursor = snapshot.cursor
   agents.value = snapshot.agents; conversations.value = transcriptStore.conversations; cursor = snapshot.cursor
@@ -537,7 +540,7 @@ function connectEvents() {
   eventSource?.close()
   eventSource?.close()
   if (disposed) return
   if (disposed) return
   if (!navigator.onLine) { suspendEvents(); return }
   if (!navigator.onLine) { suspendEvents(); return }
-  const source = new EventSource(`/api/app/events/stream?after=${cursor}${patchEvents ? '&format=patch-v1' : ''}`)
+  const source = new EventSource(`/api/app/events/stream?after=${cursor}&toolDetails=lazy${patchEvents ? '&format=patch-v1' : ''}`)
   eventSource = source
   eventSource = source
   source.addEventListener('workspace', event => {
   source.addEventListener('workspace', event => {
     if (disposed || eventSource !== source) return
     if (disposed || eventSource !== source) return
@@ -913,7 +916,7 @@ async function loadOlder() {
   const taskId = activeTask.value?.id, own = generation
   const taskId = activeTask.value?.id, own = generation
   try {
   try {
     const r = await apiRequest<{ messages: Message[]; cursor?: number }>(
     const r = await apiRequest<{ messages: Message[]; cursor?: number }>(
-      `/api/app/conversations/${c.id}/messages?limit=50&before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
+      `/api/app/conversations/${c.id}/messages?limit=50&toolDetails=lazy&before=${messages.value[0]?.seq ?? 0}${activeTask.value ? `&taskId=${activeTask.value.id}` : ''}`,
     )
     )
     if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
     if (own !== generation || active.value?.id !== c.id || activeTask.value?.id !== taskId) return
     const current = transcriptStore.get(c.id, taskId)
     const current = transcriptStore.get(c.id, taskId)

+ 2 - 0
src/server/app.ts

@@ -1,4 +1,5 @@
 import { unreadSnapshot } from './unreadCenter.js'
 import { unreadSnapshot } from './unreadCenter.js'
+import { jsonCompression } from './jsonCompression.js'
 import {ExecutionSettings} from './executionSettings.js'
 import {ExecutionSettings} from './executionSettings.js'
 import {CredentialVaultCoordinator} from './credentialVault/coordinator.js'
 import {CredentialVaultCoordinator} from './credentialVault/coordinator.js'
 import {createCredentialVaultClient} from './credentialVault/localClient.js'
 import {createCredentialVaultClient} from './credentialVault/localClient.js'
@@ -411,6 +412,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     }
     }
     await next()
     await next()
   })
   })
+  app.use(jsonCompression)
   app.use(async (ctx,next)=>{
   app.use(async (ctx,next)=>{
     // Count admitted requests before async parsing/authorization. A restart must
     // Count admitted requests before async parsing/authorization. A restart must
     // not race a request which has not yet reached the runtime's task counters.
     // not race a request which has not yet reached the runtime's task counters.

+ 3 - 2
src/server/chatCache.ts

@@ -1,4 +1,5 @@
 import { ChatTranscriptStore } from './chatTranscriptStore.js'
 import { ChatTranscriptStore } from './chatTranscriptStore.js'
+import { sessionForList } from './chatPresentation.js'
 import { isFinalChatResult } from './chatUnread.js'
 import { isFinalChatResult } from './chatUnread.js'
 import { HttpError } from './errors.js'
 import { HttpError } from './errors.js'
 import { createHash, randomUUID } from 'node:crypto'
 import { createHash, randomUUID } from 'node:crypto'
@@ -356,7 +357,7 @@ export class ChatCacheStore {
       LIMIT ? OFFSET ?`).all(...args, limit, offset) as Array<Record<string,any>>
       LIMIT ? OFFSET ?`).all(...args, limit, offset) as Array<Record<string,any>>
     const unread = new Map(this.unread(owner, profile).sessions.map(row => [JSON.stringify([row.profile, row.session_id]), row.final_unread_count]))
     const unread = new Map(this.unread(owner, profile).sessions.map(row => [JSON.stringify([row.profile, row.session_id]), row.final_unread_count]))
     return {status:200,headers:new Headers({'content-type':'application/json'}),body:Buffer.from(JSON.stringify({
     return {status:200,headers:new Headers({'content-type':'application/json'}),body:Buffer.from(JSON.stringify({
-      sessions:rows.map(r=>({...JSON.parse(r.data),...(r.local_title!=null?{title:r.local_title}:{}),id:r.session_id,profile:r.profile,owned:true,pinned:!!r.pin,
+      sessions:rows.map(r=>({...sessionForList(JSON.parse(r.data)),...(r.local_title!=null?{title:r.local_title}:{}),id:r.session_id,profile:r.profile,owned:true,pinned:!!r.pin,
         final_unread_count:unread.get(JSON.stringify([r.profile,r.session_id]))??0,
         final_unread_count:unread.get(JSON.stringify([r.profile,r.session_id]))??0,
         sync_state:r.sync_state,history_complete:!!r.complete})), total,offset,limit,
         sync_state:r.sync_state,history_complete:!!r.complete})), total,offset,limit,
       sync_state:this.needsListMetadata(owner,profile)?'syncing':'current'
       sync_state:this.needsListMetadata(owner,profile)?'syncing':'current'
@@ -1038,7 +1039,7 @@ export class ChatCacheStore {
     })
     })
     const offset = Math.max(0, Math.trunc(page.offset ?? 0))
     const offset = Math.max(0, Math.trunc(page.offset ?? 0))
     const limit = Math.max(1, Math.min(500, Math.trunc(page.limit ?? 100)))
     const limit = Math.max(1, Math.min(500, Math.trunc(page.limit ?? 100)))
-    const sessions = allSessions.slice(offset, offset + limit)
+    const sessions = allSessions.slice(offset, offset + limit).map(value => object(value) ? sessionForList(object(value)!) : value)
     const headers = new Headers(response.headers)
     const headers = new Headers(response.headers)
     headers.delete('content-length')
     headers.delete('content-length')
     return {
     return {

+ 30 - 0
src/server/chatPresentation.ts

@@ -0,0 +1,30 @@
+import type { WorkspaceEvent, WorkspaceMessage } from '../shared/workspace.js'
+
+/** Display projections never change the durable history or model context. */
+export function sessionForList(value: Record<string, unknown>): Record<string, unknown> {
+  const { system_prompt: _prompt, systemPrompt: _camelPrompt, tool_names: _tools, toolNames: _camelTools, ...summary } = value
+  return summary
+}
+
+export function workspaceMessageForClient(message: WorkspaceMessage): WorkspaceMessage {
+  return { ...message, tools: message.tools.map((tool, index) => {
+    if (Buffer.byteLength(JSON.stringify(tool)) <= 8192) return tool
+    const summary: Record<string, unknown> = {}
+    for (const key of ['id', 'name', 'tool_name', 'status', 'duration_seconds', 'durationSeconds', 'duration_s']) {
+      if (tool[key] !== undefined) summary[key] = tool[key]
+    }
+    if (!summary.status && (tool.result !== undefined || tool.output !== undefined)) summary.status = 'completed'
+    if (typeof tool.error === 'string') summary.error = tool.error.slice(0, 512)
+    if (typeof tool.preview === 'string') summary.preview = tool.preview.slice(0, 512)
+    const query = new URLSearchParams({ revision: String(message.revision ?? 0) })
+    if (tool.id != null) query.set('toolId', String(tool.id))
+    summary.detailsUrl = `/api/app/conversations/${encodeURIComponent(message.conversationId)}/messages/${encodeURIComponent(message.id)}/tools/${index}?${query}`
+    return summary
+  }) }
+}
+
+export function workspaceEventForClient(event: WorkspaceEvent): WorkspaceEvent {
+  return event.type === 'message.changed'
+    ? { ...event, data: workspaceMessageForClient(event.data as WorkspaceMessage) }
+    : event
+}

+ 2 - 1
src/server/chatTranscriptApi.ts

@@ -4,6 +4,7 @@ import type { ChatCacheCoordinator } from './chatCache.js'
 import type { LocalAuthStore } from './localAuth.js'
 import type { LocalAuthStore } from './localAuth.js'
 import { HttpError } from './errors.js'
 import { HttpError } from './errors.js'
 import { CHAT_TRANSCRIPT_FEATURE } from '../shared/chatTranscript.js'
 import { CHAT_TRANSCRIPT_FEATURE } from '../shared/chatTranscript.js'
+import { sessionForList } from './chatPresentation.js'
 
 
 export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthStore):Router {
 export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthStore):Router {
   const router=new Router(),store=cache.store,transcripts=store.transcripts
   const router=new Router(),store=cache.store,transcripts=store.transcripts
@@ -27,7 +28,7 @@ export function chatTranscriptRouter(cache:ChatCacheCoordinator,auth:LocalAuthSt
     try{
     try{
       transcripts.seed(owner,profile,id)
       transcripts.seed(owner,profile,id)
       const detail=store.localDetail(owner,profile,id)!
       const detail=store.localDetail(owner,profile,id)!
-      ctx.body=transcripts.snapshot(owner,profile,id,JSON.parse(detail.response.body.toString()),detail.state,before,limit)
+      ctx.body=transcripts.snapshot(owner,profile,id,sessionForList(JSON.parse(detail.response.body.toString())),detail.state,before,limit)
       store.db.exec('RELEASE transcript_snapshot')
       store.db.exec('RELEASE transcript_snapshot')
     }catch(error){store.db.exec('ROLLBACK TO transcript_snapshot; RELEASE transcript_snapshot');throw error}
     }catch(error){store.db.exec('ROLLBACK TO transcript_snapshot; RELEASE transcript_snapshot');throw error}
   })
   })

+ 26 - 0
src/server/jsonCompression.ts

@@ -0,0 +1,26 @@
+import type Koa from 'koa'
+import { gzip } from 'node:zlib'
+import { promisify } from 'node:util'
+
+const compress = promisify(gzip)
+
+/** Compress completed API documents only; SSE must flush each event unchanged. */
+export const jsonCompression: Koa.Middleware = async (ctx, next) => {
+  await next()
+  if (!ctx.path.startsWith('/api/') || ctx.method === 'HEAD' || ctx.status !== 200
+    || ctx.respond === false || ctx.res.headersSent || !ctx.response.is('application/json')
+    || ctx.response.get('Content-Encoding') || ctx.body == null) return
+  const value = ctx.body
+  if (!Buffer.isBuffer(value) && typeof value !== 'string' && typeof value !== 'object') return
+  if (typeof value === 'object' && !Buffer.isBuffer(value) && 'pipe' in value) return
+  const body = Buffer.isBuffer(value) ? value : Buffer.from(typeof value === 'string' ? value : JSON.stringify(value))
+  if (body.length < 1024) return
+  ctx.vary('Accept-Encoding')
+  if (ctx.acceptsEncodings('gzip', 'identity') !== 'gzip') return
+  const compressed = await compress(body)
+  if (compressed.length >= body.length) return
+  ctx.set('Content-Encoding', 'gzip')
+  ctx.remove('Content-Length')
+  ctx.body = compressed
+  ctx.type = 'application/json'
+}

+ 27 - 4
src/server/workspaceRoutes.ts

@@ -2,6 +2,7 @@ import { WORKSPACE_PATCH_CAPABILITY } from '../shared/workspaceMessagePatch.js'
 import { KNOWLEDGE_FEATURES } from './workspaceKnowledge.js'
 import { KNOWLEDGE_FEATURES } from './workspaceKnowledge.js'
 import { workspaceKnowledgeRouter } from './workspaceKnowledgeRoutes.js'
 import { workspaceKnowledgeRouter } from './workspaceKnowledgeRoutes.js'
 import { workspaceDetail, streamWorkspace } from './workspaceSync.js'
 import { workspaceDetail, streamWorkspace } from './workspaceSync.js'
+import { workspaceMessageForClient } from './chatPresentation.js'
 import { readServerIdentity } from './serverIdentity.js'
 import { readServerIdentity } from './serverIdentity.js'
 import { randomUUID } from 'node:crypto'
 import { randomUUID } from 'node:crypto'
 import Router from '@koa/router'
 import Router from '@koa/router'
@@ -318,7 +319,24 @@ export function workspaceRouter(
   router.get('/api/app/conversations/:id', (ctx) => {
   router.get('/api/app/conversations/:id', (ctx) => {
     ctx.body = workspaceDetail(store, runtime, owner(ctx), ctx.params.id,
     ctx.body = workspaceDetail(store, runtime, owner(ctx), ctx.params.id,
       typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
       typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined,
-      Math.max(1, Math.min(100, number(ctx.query.limit, 100))))
+      Math.max(1, Math.min(100, number(ctx.query.limit, 100))), ctx.query.toolDetails === 'lazy')
+  })
+  router.get('/api/app/conversations/:id/messages/:messageId/tools/:index', ctx => {
+    const user = owner(ctx)
+    const conversation = store.require<WorkspaceConversation>(user, 'conversation', ctx.params.id)
+    for (const id of conversation.memberIds) {
+      const agent = store.get<WorkspaceAgent>(user, 'agent', id)
+      if (agent && !auth.canUseSource(user, agent.nodeId, agent.profile)) throw new HttpError(403, '没有此机器人的权限', 'agent_source_forbidden')
+    }
+    const message = store.require<WorkspaceMessage>(user, 'message', ctx.params.messageId)
+    if (message.conversationId !== conversation.id || message.visible === false) throw new HttpError(404, '工具记录不存在', 'tool_not_found')
+    const index = number(ctx.params.index, -1)
+    const tool = typeof ctx.query.toolId === 'string'
+      ? message.tools.find(tool => String(tool.id) === ctx.query.toolId)
+      : message.tools[index]
+    if (!tool) throw new HttpError(404, '工具记录不存在', 'tool_not_found')
+    ctx.set('Cache-Control', 'no-store')
+    ctx.body = { input: tool.arguments ?? tool.args ?? tool.input, output: tool.error ?? tool.result ?? tool.output, revision: message.revision }
   })
   })
   router.patch('/api/app/conversations/:id', async (ctx) => {
   router.patch('/api/app/conversations/:id', async (ctx) => {
     const user = owner(ctx), input = body(ctx)
     const user = owner(ctx), input = body(ctx)
@@ -348,7 +366,7 @@ export function workspaceRouter(
         Math.min(200, number(ctx.query.limit, 100)),
         Math.min(200, number(ctx.query.limit, 100)),
         false,
         false,
         task?.id,
         task?.id,
-      ),
+      ).map(message => ctx.query.toolDetails === 'lazy' ? workspaceMessageForClient(message) : message),
       cursor: store.cursor(user),
       cursor: store.cursor(user),
     }
     }
   })
   })
@@ -401,10 +419,15 @@ export function workspaceRouter(
     const user = owner(ctx)
     const user = owner(ctx)
     const conversations = store.list<WorkspaceConversation>(user, 'conversation')
     const conversations = store.list<WorkspaceConversation>(user, 'conversation')
       .map(c => store.conversationSummary(user, c)).sort(compareWorkspaceConversations)
       .map(c => store.conversationSummary(user, c)).sort(compareWorkspaceConversations)
-    const details = conversations.filter(c => !c.archived).map(c => workspaceDetail(store, runtime, user, c.id))
+    const partialDetails = ctx.query.details === 'selected'
+    const taskId = partialDetails && typeof ctx.query.taskId === 'string' ? ctx.query.taskId : undefined
+    // A stale task link must still load the list; the detail route handles its fallback.
+    const details = conversations.filter(c => !c.archived && (!partialDetails || c.id === ctx.query.conversationId)
+      && (!taskId || store.get<{ conversationId: string }>(user, 'conversation-task', taskId)?.conversationId === c.id))
+      .map(c => workspaceDetail(store, runtime, user, c.id, taskId, 50, ctx.query.toolDetails === 'lazy'))
     ctx.set('Cache-Control', 'no-store')
     ctx.set('Cache-Control', 'no-store')
     ctx.body = { projects: runtime.knowledge.projects(user), agents: store.list<WorkspaceAgent>(user, 'agent').map(a => store.agentSummary(a)),
     ctx.body = { projects: runtime.knowledge.projects(user), agents: store.list<WorkspaceAgent>(user, 'agent').map(a => store.agentSummary(a)),
-      conversations, details, cursor: store.cursor(user), serverIdentity: readServerIdentity(store) }
+      conversations, details, partialDetails, cursor: store.cursor(user), serverIdentity: readServerIdentity(store) }
   })
   })
   router.get('/api/app/events/stream', ctx => streamWorkspace(ctx, store, auth))
   router.get('/api/app/events/stream', ctx => streamWorkspace(ctx, store, auth))
   router.get('/api/app/events', (ctx) => {
   router.get('/api/app/events', (ctx) => {

+ 18 - 5
src/server/workspaceSync.ts

@@ -3,11 +3,12 @@ import type { LocalAuthStore } from './localAuth.js'
 import type { WorkspaceStore } from './workspaceStore.js'
 import type { WorkspaceStore } from './workspaceStore.js'
 import type { WorkspaceRuntime } from './workspaceRuntime.js'
 import type { WorkspaceRuntime } from './workspaceRuntime.js'
 import { readServerIdentity } from './serverIdentity.js'
 import { readServerIdentity } from './serverIdentity.js'
-import type { WorkspaceConversation, WorkspaceEvent, WorkspaceInteraction, WorkspaceRun } from '../shared/workspace.js'
+import { workspaceMessageForClient, workspaceEventForClient } from './chatPresentation.js'
+import type { WorkspaceConversation, WorkspaceEvent, WorkspaceInteraction, WorkspaceMessage, WorkspaceRun } from '../shared/workspace.js'
 
 
 /** Bot transcripts are a local projection. Snapshot and cursor are read in the same synchronous turn. */
 /** Bot transcripts are a local projection. Snapshot and cursor are read in the same synchronous turn. */
 export function workspaceDetail(store: WorkspaceStore, runtime: WorkspaceRuntime, owner: string,
 export function workspaceDetail(store: WorkspaceStore, runtime: WorkspaceRuntime, owner: string,
-  conversationId: string, taskId?: string, limit = 50) {
+  conversationId: string, taskId?: string, limit = 50, lazyTools = false) {
   const conversation = store.require<WorkspaceConversation>(owner, 'conversation', conversationId)
   const conversation = store.require<WorkspaceConversation>(owner, 'conversation', conversationId)
   const task = store.resolveTask(owner, conversationId, taskId)
   const task = store.resolveTask(owner, conversationId, taskId)
   const activeRunId = task?.activeRunId ?? (conversation.kind === 'direct' ? conversation.activeRunId : undefined)
   const activeRunId = task?.activeRunId ?? (conversation.kind === 'direct' ? conversation.activeRunId : undefined)
@@ -16,7 +17,7 @@ export function workspaceDetail(store: WorkspaceStore, runtime: WorkspaceRuntime
     conversation: store.conversationSummary(owner, conversation), task: task ?? null,
     conversation: store.conversationSummary(owner, conversation), task: task ?? null,
     tasks: conversation.kind === 'group' ? store.tasks(owner, conversationId) : [],
     tasks: conversation.kind === 'group' ? store.tasks(owner, conversationId) : [],
     assignments: task ? runtime.tasks.assignments(owner, task.id) : [],
     assignments: task ? runtime.tasks.assignments(owner, task.id) : [],
-    messages: page.slice(-limit), hasOlder: page.length > limit,
+    messages: page.slice(-limit).map(message => lazyTools ? workspaceMessageForClient(message) : message), hasOlder: page.length > limit,
     hiddenMessageIds: store.hiddenMessageIds(owner, conversationId, task?.id),
     hiddenMessageIds: store.hiddenMessageIds(owner, conversationId, task?.id),
     run: activeRunId ? store.require<WorkspaceRun>(owner, 'run', activeRunId) : null,
     run: activeRunId ? store.require<WorkspaceRun>(owner, 'run', activeRunId) : null,
     interactions: store.list<WorkspaceInteraction>(owner, 'interaction')
     interactions: store.list<WorkspaceInteraction>(owner, 'interaction')
@@ -31,6 +32,7 @@ export function streamWorkspace(ctx: Koa.Context, store: WorkspaceStore, auth: L
   const owner = auth.require(ctx).id, version = auth.pushAuthorizationVersion(owner)
   const owner = auth.require(ctx).id, version = auth.pushAuthorizationVersion(owner)
   const cookie = ctx.get('cookie')
   const cookie = ctx.get('cookie')
   const patches = store.messagePatchesEnabled && ctx.query.format === 'patch-v1'
   const patches = store.messagePatchesEnabled && ctx.query.format === 'patch-v1'
+  const lazyTools = ctx.query.toolDetails === 'lazy'
   const res = ctx.res
   const res = ctx.res
   const raw = ctx.get('last-event-id') || String(ctx.query.after ?? '0')
   const raw = ctx.get('last-event-id') || String(ctx.query.after ?? '0')
   let cursor = /^\d+$/.test(raw) ? Number(raw) : 0
   let cursor = /^\d+$/.test(raw) ? Number(raw) : 0
@@ -57,7 +59,7 @@ export function streamWorkspace(ctx: Koa.Context, store: WorkspaceStore, auth: L
   }
   }
   const emit = (event: WorkspaceEvent) => {
   const emit = (event: WorkspaceEvent) => {
     if (event.seq <= cursor) return
     if (event.seq <= cursor) return
-    write(`id: ${event.seq}\nevent: workspace\ndata: ${JSON.stringify(event)}\n\n`)
+    write(`id: ${event.seq}\nevent: workspace\ndata: ${JSON.stringify(lazyTools ? workspaceEventForClient(event) : event)}\n\n`)
     cursor = event.seq
     cursor = event.seq
     if (event.type.startsWith('message.')) lastTextAt = Date.now()
     if (event.type.startsWith('message.')) lastTextAt = Date.now()
   }
   }
@@ -73,7 +75,18 @@ export function streamWorkspace(ctx: Koa.Context, store: WorkspaceStore, auth: L
     if (patches) {
     if (patches) {
       // A new live subscription may not have this background task in its snapshot.
       // A new live subscription may not have this background task in its snapshot.
       // The first live update is a full committed baseline; replay stays revision-checked.
       // The first live update is a full committed baseline; replay stays revision-checked.
-      emit(wire.type === 'message.patch' && event.type === 'message.changed' && id && !knownMessages.has(id) ? event : wire)
+      let baseline = wire
+      if (wire.type === 'message.patch' && id && !knownMessages.has(id)) {
+        if (event.type === 'message.changed') baseline = event
+        else if (lazyTools) {
+          // Partial snapshots do not include background messages. A replayed
+          // first patch also needs a baseline, scoped to this authenticated owner.
+          const message = store.get<WorkspaceMessage>(owner, 'message', id)
+          if (!message) { write('event: reset\ndata: {}\n\n'); close(); return }
+          baseline = { ...event, type: 'message.changed', data: store.messageForDisplay(owner, message) }
+        }
+      }
+      emit(baseline)
       if (id && event.type.startsWith('message.')) {
       if (id && event.type.startsWith('message.')) {
         knownMessages.delete(id); knownMessages.add(id)
         knownMessages.delete(id); knownMessages.add(id)
         while (knownMessages.size > 64) knownMessages.delete(knownMessages.values().next().value!)
         while (knownMessages.size > 64) knownMessages.delete(knownMessages.values().next().value!)

+ 25 - 0
tests/client/toolTrace.test.ts

@@ -0,0 +1,25 @@
+import { flushPromises, mount } from '@vue/test-utils'
+import { afterEach, expect, it, vi } from 'vitest'
+import ToolTrace from '@/components/messages/ToolTrace.vue'
+import { apiRequest } from '@/api/client'
+
+vi.mock('@/api/client', () => ({ apiRequest: vi.fn() }))
+afterEach(() => vi.clearAllMocks())
+
+it('fetches tool details only when expanded, reuses them, and loads a new revision without showing stale output', async () => {
+  vi.mocked(apiRequest).mockResolvedValue({ input: 'input', output: 'complete output' })
+  const tool = { id: 'tool', name: 'read', status: 'success' as const, detailsUrl: '/api/app/tool?revision=1' }
+  const wrapper = mount(ToolTrace, { props: { tool } })
+  expect(apiRequest).not.toHaveBeenCalled()
+  await wrapper.get('button').trigger('click'); await flushPromises()
+  expect(apiRequest).toHaveBeenCalledTimes(1)
+  expect(wrapper.text()).toContain('complete output')
+  await wrapper.get('button').trigger('click'); await wrapper.get('button').trigger('click'); await flushPromises()
+  expect(apiRequest).toHaveBeenCalledTimes(1)
+  vi.mocked(apiRequest).mockResolvedValue({ output: 'new revision' })
+  await wrapper.setProps({ tool: { ...tool, detailsUrl: '/api/app/tool?revision=2' } }); await flushPromises()
+  expect(apiRequest).toHaveBeenCalledTimes(2)
+  expect(wrapper.text()).toContain('new revision')
+  expect(wrapper.text()).not.toContain('complete output')
+  wrapper.unmount()
+})

+ 12 - 0
tests/client/workspaceTranscript.test.ts

@@ -8,6 +8,18 @@ const detail = (): WorkspaceDetail => ({ conversation, messages: [message('old',
 const event = (seq: number, value: WorkspaceMessage): WorkspaceEvent => ({ seq, type: 'message.changed', conversationId: value.conversationId, data: value })
 const event = (seq: number, value: WorkspaceMessage): WorkspaceEvent => ({ seq, type: 'message.changed', conversationId: value.conversationId, data: value })
 
 
 describe('Bot transcript cache', () => {
 describe('Bot transcript cache', () => {
+  it('retains unselected transcripts as stale after a partial snapshot and accepts subsequent patches', () => {
+    const store = new WorkspaceTranscriptStore(), initial = detail()
+    initial.messages[1] = { ...initial.messages[1]!, revision: 1 }
+    store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
+    store.hydrate({ agents: [], conversations: [conversation], details: [], partialDetails: true, cursor: 11 })
+    expect(store.get('c')!.messages).toEqual(initial.messages)
+    expect(store.staleDetails.has('c:')).toBe(true)
+    store.apply({ seq: 12, type: 'message.patch', conversationId: 'c', data: { id: 'live', conversationId: 'c', baseRevision: 1, revision: 2, contentAppend: ' updated', reasoningAppend: '' } })
+    expect(store.get('c')!.messages[1]!.content).toBe('live updated')
+    store.hydrate({ agents: [], conversations: [], details: [], partialDetails: true, cursor: 13 })
+    expect(store.get('c')).toBeUndefined()
+  })
   it.each([undefined, null, { agents: [], conversations: [], details: [] }, { cursor: 0 }])('keeps the last transcript when a snapshot response is incomplete', snapshot => {
   it.each([undefined, null, { agents: [], conversations: [], details: [] }, { cursor: 0 }])('keeps the last transcript when a snapshot response is incomplete', snapshot => {
     const store = new WorkspaceTranscriptStore(), initial = detail()
     const store = new WorkspaceTranscriptStore(), initial = detail()
     store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })
     store.hydrate({ agents: [], conversations: [conversation], details: [initial], cursor: 10 })

+ 31 - 0
tests/e2e/workspace-chat.spec.ts

@@ -877,6 +877,37 @@ test('Bot transcript switches from cache and preserves the viewport during histo
   }
   }
 })
 })
 
 
+test('Bot startup loads only the selected transcript and fetches large tool output after expansion', async ({ page }) => {
+  await login(page)
+  const bootstrap = await (await page.request.get('/api/app/bootstrap')).json()
+  const headers = { 'X-CSRF-Token': bootstrap.csrfToken }
+  const seeded = await page.request.post('/__test/workspace-transcript?tools=large', { headers })
+  expect(seeded.ok(), await seeded.text()).toBe(true)
+  const seed = await seeded.json()
+  const requests: string[] = []
+  page.on('request', request => { if (request.method() === 'GET') requests.push(request.url()) })
+  const snapshot = page.waitForResponse(response => response.url().includes('/api/app/workspace/snapshot?'))
+  await page.goto('/conversations')
+  expect((await (await snapshot).json()).details).toEqual([])
+  await page.locator(`.desktop-sidebar [data-sidebar-id="${seed.conversationId}"]`).click()
+  await expect(page.locator('.message')).toHaveCount(50)
+  await page.locator('.composer-tool[title="显示思考"]').click()
+  await page.locator('.turn-trace summary').click()
+  const tool = page.locator('.tool-trace').filter({ hasText: 'read_file' })
+  await expect(tool).toBeVisible()
+  expect(requests.some(url => url.includes('/tools/'))).toBe(false)
+  await tool.getByRole('button', { name: /read_file/ }).click()
+  await expect(tool.locator('pre').last()).toContainText('工具输出末尾')
+  expect(requests.filter(url => url.includes('/tools/'))).toHaveLength(1)
+  const reloaded = page.waitForResponse(response => response.url().includes('/api/app/workspace/snapshot?'))
+  await page.reload()
+  const selected = await (await reloaded).json()
+  expect(selected.details).toHaveLength(1)
+  expect(selected.details[0].conversation.id).toBe(seed.conversationId)
+  expect(JSON.stringify(selected)).not.toContain('工具输出末尾')
+  await expect(page.locator('.message')).toHaveCount(50)
+})
+
 test('account update checks show loading, available versions and retry on desktop and phone', async ({ page }, testInfo) => {
 test('account update checks show loading, available versions and retry on desktop and phone', async ({ page }, testInfo) => {
   await login(page)
   await login(page)
   const current = { schemaVersion: 1, releaseVersion: '0.4.17', webVersion: '0.4.17', gitTag: 'v0.4.17' }
   const current = { schemaVersion: 1, releaseVersion: '0.4.17', webVersion: '0.4.17', gitTag: 'v0.4.17' }

+ 3 - 2
tests/fixtures/workspace-server.ts

@@ -421,12 +421,13 @@ runtime.app.use((ctx, next) => {
     runtime.workspace.saveMessage(owner, { ...message, content: message.content + '\n\n实时追加的内容', status: 'streaming' })
     runtime.workspace.saveMessage(owner, { ...message, content: message.content + '\n\n实时追加的内容', status: 'streaming' })
     ctx.body = { ok: true }; return
     ctx.body = { ok: true }; return
   }
   }
-  const agent = runtime.workspace.createAgent(owner, { name: '长会话性能验收', profile: 'default' })
+  const agent = runtime.workspace.createAgent(owner, { name: ctx.query.tools === 'large' ? '大工具按需加载验收' : '长会话性能验收', profile: 'default' })
   const conversation = runtime.workspace.list<any>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)!
   const conversation = runtime.workspace.list<any>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)!
   for (let index = 1; index <= 300; index++) runtime.workspace.saveMessage(owner, {
   for (let index = 1; index <= 300; index++) runtime.workspace.saveMessage(owner, {
     id: randomUUID(), conversationId: conversation.id, seq: index, role: index % 2 ? 'user' : 'assistant',
     id: randomUUID(), conversationId: conversation.id, seq: index, role: index % 2 ? 'user' : 'assistant',
     content: `消息 ${index}\n\n${'用于验证长会话缓存与滚动位置。'.repeat(12)}`, reasoning: '', status: 'complete',
     content: `消息 ${index}\n\n${'用于验证长会话缓存与滚动位置。'.repeat(12)}`, reasoning: '', status: 'complete',
-    attachments: [], tools: [], createdAt: Date.now() + index,
+    attachments: [], tools: index === 300 && ctx.query.tools === 'large'
+      ? [{ id: 'large-tool', name: 'read_file', status: 'completed', arguments: { path: '/tmp/large.txt' }, result: '完整工具输出'.repeat(3000) + '工具输出末尾' }] : [], createdAt: Date.now() + index,
   })
   })
   ctx.body = { agentId: agent.id, conversationId: conversation.id }
   ctx.body = { agentId: agent.id, conversationId: conversation.id }
 })
 })

+ 14 - 0
tests/server/chatCache.test.ts

@@ -30,6 +30,20 @@ afterEach(() => {
 })
 })
 
 
 describe('durable source=web chat cache', () => {
 describe('durable source=web chat cache', () => {
+  it('keeps model context in storage while excluding it from ordinary and native-history lists', () => {
+    const f = fixture()
+    f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
+    const metadata = { id: sessionID, title: 'Title', model: 'model', system_prompt: 'context'.repeat(10000), tool_names: ['tool'], model_config: { model: 'model' } }
+    f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response(metadata))
+    const item = JSON.parse(f.store.localList(owner, profile, {}).body.toString()).sessions[0]
+    expect(item).toMatchObject({ id: sessionID, title: 'Title', model_config: { model: 'model' } })
+    expect(item).not.toHaveProperty('system_prompt')
+    expect(item).not.toHaveProperty('tool_names')
+    expect(JSON.parse(f.store.localDetail(owner, profile, sessionID)!.response.body.toString()).system_prompt).toBe(metadata.system_prompt)
+    const native = f.store.excludeOwnedSessionsFromList(owner, profile, response({ sessions: [{ ...metadata, id: 'native' }], total: 1 }))
+    expect(JSON.parse(native.body.toString()).sessions[0]).not.toHaveProperty('system_prompt')
+    f.store.close()
+  })
   it('keeps session metadata when message envelopes only contain session_id and advances activity from messages', () => {
   it('keeps session metadata when message envelopes only contain session_id and advances activity from messages', () => {
     const f = fixture()
     const f = fixture()
     f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
     f.store.recordRoute(owner, profile, sessionID, 'runtime-web')

+ 25 - 0
tests/server/jsonCompression.test.ts

@@ -0,0 +1,25 @@
+// @vitest-environment node
+import Koa from 'koa'
+import request from 'supertest'
+import { expect, it } from 'vitest'
+import { jsonCompression } from '../../src/server/jsonCompression.js'
+
+it('compresses JSON for supported clients and leaves identity and SSE responses intact', async () => {
+  const app = new Koa(), value = { content: 'large result'.repeat(2000) }
+  app.use(jsonCompression)
+  app.use(ctx => {
+    if (ctx.path === '/api/events') { ctx.type = 'text/event-stream';ctx.body = 'event: ready\ndata: {}\n\n' }
+    else ctx.body = value
+  })
+  const compressed = await request(app.callback()).get('/api/snapshot').set('Accept-Encoding', 'gzip').expect(200)
+  expect(compressed.headers['content-encoding']).toBe('gzip')
+  expect(compressed.headers.vary).toContain('Accept-Encoding')
+  expect(compressed.body).toEqual(value)
+  expect(Number(compressed.headers['content-length'])).toBeLessThan(1024)
+  const identity = await request(app.callback()).get('/api/snapshot').set('Accept-Encoding', 'identity').expect(200)
+  expect(identity.headers['content-encoding']).toBeUndefined()
+  expect(identity.body).toEqual(value)
+  const stream = await request(app.callback()).get('/api/events').set('Accept-Encoding', 'gzip').expect(200)
+  expect(stream.headers['content-encoding']).toBeUndefined()
+  expect(stream.text).toBe('event: ready\ndata: {}\n\n')
+})

+ 31 - 0
tests/server/workspaceRoutes.test.ts

@@ -126,6 +126,37 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
   rmSync(home, { recursive: true, force: true })
 })
 })
 describe('application workspace HTTP contract', () => {
 describe('application workspace HTTP contract', () => {
+  it('loads only the selected transcript and fetches large tool results without changing stored history', async () => {
+    const store = runtime.workspace
+    store.createAgent('first', { name: 'First Bot', profile: 'default' })
+    store.createAgent('first', { name: 'Second Bot', profile: 'default' })
+    const [firstChat, secondChat] = store.list<WorkspaceConversation>('first', 'conversation')
+    const output = 'large-private-tool-result'.repeat(2000), id = randomUUID()
+    store.saveMessage('first', { id, conversationId: firstChat!.id, seq: 0, role: 'assistant',
+      content: '已完成', reasoning: '', status: 'complete', tools: [{ id: 'large-tool', name: 'read_file', result: output }], attachments: [], createdAt: Date.now() })
+    const list = (await req('get', '/api/app/workspace/snapshot?details=selected&toolDetails=lazy').expect(200)).body
+    expect(list.conversations).toHaveLength(2)
+    expect(list.details).toEqual([])
+    expect(list.partialDetails).toBe(true)
+    const staleTask = (await req('get', `/api/app/workspace/snapshot?details=selected&conversationId=${firstChat!.id}&taskId=deleted-task`).expect(200)).body
+    expect(staleTask.details).toEqual([])
+    expect(staleTask.conversations).toHaveLength(2)
+    const snapshot = (await req('get', `/api/app/workspace/snapshot?details=selected&toolDetails=lazy&conversationId=${firstChat!.id}`).expect(200)).body
+    expect(snapshot.details).toHaveLength(1)
+    const tool = snapshot.details[0].messages[0].tools[0]
+    expect(tool).toMatchObject({ id: 'large-tool', status: 'completed' })
+    expect(tool).not.toHaveProperty('result')
+    expect((await req('get', tool.detailsUrl).expect(200)).body.output).toBe(output)
+    await req('get', tool.detailsUrl, 'other-user').expect(404)
+    await req('get', tool.detailsUrl.replace(firstChat!.id, secondChat!.id)).expect(404)
+    const authorization = vi.spyOn(runtime.auth, 'canUseSource').mockReturnValue(false)
+    await req('get', tool.detailsUrl).expect(403)
+    authorization.mockRestore()
+    store.saveMessage('first', { ...store.require<any>('first', 'message', id), visible: false })
+    await req('get', tool.detailsUrl).expect(404)
+    expect(store.require<any>('first', 'message', id).tools[0].result).toBe(output)
+    expect((await req('get', '/api/app/workspace/snapshot').expect(200)).body.details).toHaveLength(2)
+  })
   it.each(['empty', 'existing'] as const)('serves a complete %s Bot snapshot before any Runner or virtual machine is created', async state => {
   it.each(['empty', 'existing'] as const)('serves a complete %s Bot snapshot before any Runner or virtual machine is created', async state => {
     const store = runtime.workspace
     const store = runtime.workspace
     expect(runtime.runners.records()).toEqual([])
     expect(runtime.runners.records()).toEqual([])

+ 18 - 0
tests/server/workspaceSync.test.ts

@@ -54,6 +54,24 @@ it('requests a fresh snapshot when the database cursor has moved backwards', asy
   expect(await response.text()).toContain('event: reset')
   expect(await response.text()).toContain('event: reset')
 })
 })
 
 
+it('replays a full lightweight baseline before patches for a background chat omitted from a partial snapshot', async () => {
+  store.createAgent('owner', { name: 'Bot', nodeId: 'local', profile: 'default' })
+  const c = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
+  const m: WorkspaceMessage = { id: 'background', conversationId: c.id, seq: 0, role: 'assistant', content: 'one', reasoning: '', status: 'streaming', tools: [{ id: 'large', result: 'private-output'.repeat(2000) }], attachments: [], createdAt: 1 }
+  store.saveMessage('owner', m)
+  const after = store.cursor('owner')
+  store.saveMessage('owner', { ...m, content: 'one two' })
+  const controller = new AbortController()
+  const response = await fetch(`${url()}?after=${after}&format=patch-v1&toolDetails=lazy`, { signal: controller.signal })
+  const reader = response.body!.getReader(), decoder = new TextDecoder()
+  let text = ''
+  while (!text.includes('event: ready')) text += decoder.decode((await reader.read()).value, { stream: true })
+  const event = text.split('\n').find(line => line.startsWith('data: ') && line.includes('message.changed'))!
+  expect(JSON.parse(event.slice(6)).data).toMatchObject({ id: m.id, content: 'one two', tools: [expect.objectContaining({ detailsUrl: expect.any(String) })] })
+  expect(text).not.toContain('private-output')
+  controller.abort(); await reader.cancel().catch(() => {})
+})
+
 it('returns only the latest 50 messages with an exact hasOlder flag and a matching cursor', () => {
 it('returns only the latest 50 messages with an exact hasOlder flag and a matching cursor', () => {
   store.createAgent('owner', { name: 'Bot', nodeId: 'local', profile: 'default' })
   store.createAgent('owner', { name: 'Bot', nodeId: 'local', profile: 'default' })
   const conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
   const conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!