Kaynağa Gözat

修复聊天失效会话清理、右键操作与附件归档

samien 1 ay önce
ebeveyn
işleme
ff5557bc0c

+ 5 - 1
src/client/stores/chat.ts

@@ -300,7 +300,11 @@ export const useChatStore = defineStore('chat', () => {
   }
 
   socket.onEvent(event => {
-    if (event.type === 'gateway.ready') return
+    if (event.type === 'gateway.ready') {
+      // List validation can finish before the event channel is attached.
+      scheduleSessionListRefresh()
+      return
+    }
     const payload = record(event.payload)
     if (event.type === 'sessions.changed') {
       scheduleSessionListRefresh()

+ 8 - 2
src/server/app.ts

@@ -221,8 +221,13 @@ export function createApplication(options: ApplicationOptions = {}): Application
   realtime.broker.onNativeEvent = (owner, profile, storedId, frame) => {
     chatCache?.observe(owner, profile, storedId, frame)
     if (owner.startsWith('device:')) return
-    if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
-    const data = frame.payload ?? {}, text = JSON.stringify(data)
+    if (!['message.complete', 'attachment.staged'].includes(frame.type)) return
+    const data = frame.payload ?? {}
+    // Tool output contains examples, inputs and intermediate process files.
+    const text = frame.type === 'attachment.staged' ? JSON.stringify(data)
+      : [data.text, data.content, data.attachments, data.files]
+        .filter(value => value !== undefined)
+        .map(value => typeof value === 'string' ? value : JSON.stringify(value)).join('\n')
     const messageId = String(data.row_id ?? data.message_id ?? data.id ?? createHash('sha256').update(text).digest('hex'))
     void workspaceAssets.archiveText(owner, text, 'local', profile, storedId, messageId, frame.type === 'attachment.staged' ? 'user' : 'agent').catch(() => {})
   }
@@ -441,6 +446,7 @@ export function createApplication(options: ApplicationOptions = {}): Application
     chatPushJobs,
     chatCache,
     close: () => {
+      chatCache?.close()
       workspaceAssets.close()
       workspaceRuntime.close()
       workspaceNodes.close()

+ 83 - 1
src/server/chatCache.ts

@@ -81,6 +81,14 @@ const ACTIVITY_TIME_KEYS = ['last_active', 'last_active_at', 'last_activity_at',
 
 function mergeSessionMetadata(previous: Record<string, unknown>, incoming: Record<string, unknown>): Record<string, unknown> {
   const summary = { ...previous, ...incoming }
+  for (const flag of ['pinned', 'archived'] as const) {
+    const camel = `is${flag[0]!.toUpperCase()}${flag.slice(1)}`
+    const value = incoming[`is_${flag}`] ?? incoming[camel] ?? incoming[flag]
+    if (value === undefined) continue
+    summary[flag] = bool(value)
+    delete summary[`is_${flag}`]
+    delete summary[camel]
+  }
   // A messages response can carry title/model metadata, but its envelope is
   // not a replacement for the session detail.
   delete summary.messages
@@ -377,7 +385,7 @@ export class ChatCacheStore {
 
   applySync(owner: string, profile: string, sessionID: string, revision: number,
     pages: Array<{ key: string; kind: string; response: UpstreamResponse; startedAt: number }>, force: boolean): boolean {
-    if (this.revision(owner, profile, sessionID) !== revision) return false
+    if (!this.ownsSession(owner, profile, sessionID) || this.revision(owner, profile, sessionID) !== revision) return false
     this.db.exec('SAVEPOINT history_sync')
     try {
       if (force) this.db.prepare('DELETE FROM chat_messages WHERE owner=? AND profile=? AND session_id=?').run(owner, profile, sessionID)
@@ -513,6 +521,23 @@ export class ChatCacheStore {
       WHERE owner=? AND (session_id=? OR kind='list')`).run(owner, sessionID)
   }
 
+  recordSessionPatch(owner: string, profile: string, sessionID: string, patch: Record<string, unknown>): void {
+    const current = this.localDetail(owner, profile, sessionID)
+    if (!current) return
+    const summary = mergeSessionMetadata(parseResponse(current.response) ?? {}, patch)
+    this.db.prepare(`UPDATE chat_sessions SET data=?,sync_revision=sync_revision+1
+      WHERE owner=? AND profile=? AND session_id=?`).run(JSON.stringify(summary), owner, profile, sessionID)
+    if (typeof patch.title === 'string') this.recordAuthoritativeTitle(owner, profile, sessionID, patch.title)
+    this.markListsStale(owner)
+  }
+
+  validationCandidates(owner: string, profile: string): Array<{ profile: string; session_id: string; sync_revision: number }> {
+    // Newly created runtime sessions may not have been persisted by Hermes yet.
+    return this.db.prepare(`SELECT profile,session_id,sync_revision FROM chat_sessions
+      WHERE owner=? AND owned_at IS NOT NULL AND owned_at<? AND (?='' OR profile=?)`)
+      .all(owner, Date.now() - 60_000, profile, profile) as Array<{ profile: string; session_id: string; sync_revision: number }>
+  }
+
   deleteSession(owner: string, profile: string, sessionID: string): void {
     const assets = this.db.prepare(`SELECT DISTINCT local_path FROM chat_attachments
       WHERE owner=? AND profile=? AND session_id=? AND local_path IS NOT NULL`)
@@ -919,15 +944,62 @@ export class ChatCacheStore {
 
 export class ChatCacheCoordinator {
   private reconciling = new Map<string, Promise<void>>()
+  private validations = new Map<string, number>()
+  private validating = new Set<string>()
+  private stopped = false
   onSynchronized: (owner: string, profile: string, sessionID: string) => void = () => {}
   constructor(readonly store: ChatCacheStore, readonly upstream: UpstreamServiceSession,
     readonly mode: ChatCacheMode = 'prefer-local') {}
 
+  close(): void { this.stopped = true }
+
+  private validateList(owner: string, profile: string, load: () => Promise<UpstreamResponse>): void {
+    const key = JSON.stringify([owner, profile])
+    if (this.stopped || this.validating.has(key) || Date.now() - (this.validations.get(key) ?? 0) < 30_000) return
+    const candidates = this.store.validationCandidates(owner, profile)
+    if (!candidates.length) return
+    this.validations.set(key, Date.now())
+    this.validating.add(key)
+    void (async () => {
+      const response = await load()
+      if (this.stopped) return
+      const payload = parseResponse(response)
+      const items = payload?.sessions ?? payload?.items
+      if (!Array.isArray(items)) return
+      const existing = new Set(items.flatMap(value => {
+        const session = object(value)
+        return session ? [`${profileOf(session, profile) || 'default'}\u0000${sessionIDOf(session)}`] : []
+      }))
+      // A filtered/paginated list cannot prove deletion. Confirm each absent
+      // candidate against its own profile; outages must retain local history.
+      for (const candidate of candidates) {
+        if (this.stopped) return
+        if (existing.has(`${candidate.profile}\u0000${candidate.session_id}`)) continue
+        try {
+          const detail = await this.upstream.request(`/api/sessions/${encodeURIComponent(candidate.session_id)}`, {
+            search: new URLSearchParams({ profile: candidate.profile }), cache: 'reload',
+          })
+          if (this.stopped) return
+          if (detail.status === 404 && this.store.ownsSession(owner, candidate.profile, candidate.session_id)
+            && this.store.revision(owner, candidate.profile, candidate.session_id) === candidate.sync_revision) {
+            this.store.deleteSession(owner, candidate.profile, candidate.session_id)
+            this.onSynchronized(owner, candidate.profile, candidate.session_id)
+          }
+        } catch { /* Retain the session and retry on a later list refresh. */ }
+      }
+    })().catch(() => {}).finally(() => this.validating.delete(key))
+  }
+
   async read(owner: string, key: string, kind: string, profile: string, sessionID: string | undefined,
     load: () => Promise<UpstreamResponse>, ownedList = false,
     listPage: { offset?: number; limit?: number; archived?: string } = {}): Promise<{ response: UpstreamResponse; source: ChatCacheSource; state: ChatCacheState }> {
+    if (kind === 'list' && ownedList) this.validateList(owner, profile, load)
     if (this.mode === 'upstream-only') {
       const upstreamResponse = await load()
+      if (sessionID && upstreamResponse.status === 404) {
+        this.store.deleteSession(owner, profile, sessionID)
+        this.onSynchronized(owner, profile, sessionID)
+      }
       return {
         response: kind === 'list' && ownedList
           ? this.store.mergeOwnedSessionsIntoList(owner, profile, upstreamResponse, listPage)
@@ -954,6 +1026,10 @@ export class ChatCacheCoordinator {
     try {
       const requestStartedAt = Date.now()
       const upstreamResponse = await load()
+      if (sessionID && upstreamResponse.status === 404 && this.store.ownsSession(owner, profile, sessionID)) {
+        this.store.deleteSession(owner, profile, sessionID)
+        this.onSynchronized(owner, profile, sessionID)
+      }
       if (this.mode === 'prefer-local' && local && upstreamResponse.status >= 500) {
         return { response: projectedLocal!.response, source: 'local', state: 'stale' }
       }
@@ -1023,6 +1099,11 @@ export class ChatCacheCoordinator {
       const detailPath = `/api/sessions/${encodeURIComponent(sessionID)}`
       const detailStartedAt = Date.now()
       const detail = await this.upstream.request(detailPath, { search: new URLSearchParams({ profile }), cache: 'reload' })
+      if (this.stopped) return
+      if (detail.status === 404 && this.store.revision(owner, profile, sessionID) === revision) {
+        this.store.deleteSession(owner, profile, sessionID)
+        this.onSynchronized(owner, profile, sessionID)
+      }
       if (detail.status !== 200) throw new Error('Unable to synchronize conversation metadata')
       const detailPayload = parseResponse(detail) ?? {}
       const detailSummary = object(detailPayload.session) ?? detailPayload
@@ -1038,6 +1119,7 @@ export class ChatCacheCoordinator {
         const search = new URLSearchParams({ profile, offset: String(offset), limit: '100', order: 'latest', include_compacted: 'true' })
         const startedAt = Date.now()
         const response = await this.upstream.request(`${detailPath}/messages`, { search, cache: 'reload' })
+        if (this.stopped) return
         const payload = parseResponse(response)
         if (response.status !== 200 || !Array.isArray(payload?.messages)) throw new Error('Unable to synchronize conversation messages')
         const messages = payload.messages.flatMap(value => object(value) ? [object(value)!] : [])

+ 12 - 14
src/server/routes.ts

@@ -759,6 +759,11 @@ async function cachedChatRead(
     }
     const requestStartedAt = Date.now()
     const upstreamResponse = await load()
+    if (options.sessionID && upstreamResponse.status === 404
+      && dependencies.chatCache.store.ownsSession(user.id, options.profile, options.sessionID)) {
+      dependencies.chatCache.store.deleteSession(user.id, options.profile, options.sessionID)
+      dependencies.chatCache.onSynchronized(user.id, options.profile, options.sessionID)
+    }
     const response = options.kind === 'list' && options.ownedList
       ? dependencies.chatCache.store.mergeOwnedSessionsIntoList(
           user.id,
@@ -2147,15 +2152,7 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
       if (requestedProfile) request.profile = requestedProfile
       const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'PATCH', search, body: request })
       if (response.status >= 200 && response.status < 300) {
-        if (typeof request.title === 'string') {
-          dependencies.chatCache?.store.recordAuthoritativeTitle(
-            owner,
-            profile,
-            id,
-            request.title,
-          )
-        }
-        dependencies.chatCache?.store.markListsStale(owner)
+        dependencies.chatCache?.store.recordSessionPatch(owner, profile, id, request)
         void dependencies.chatCache?.reconcile(owner, profile, id).catch(() => {})
       }
       sendUpstreamResponse(ctx, response, jar)
@@ -2167,17 +2164,18 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async jar => {
       const profile = search.get('profile') || 'default'
       const owner = dependencies.auth.require(ctx).id
-      requireWritableOwnedSession(
-        await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
-        dependencies.chatCache?.store.ownsSession(owner, profile, id) ?? false,
-      )
+      if (!dependencies.chatCache?.store.ownsSession(owner, profile, id)) nativeSessionsReadOnly()
       const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'DELETE', search })
-      if (response.status >= 200 && response.status < 300) {
+      if ((response.status >= 200 && response.status < 300) || response.status === 404) {
         dependencies.chatCache?.store.deleteSession(
           owner,
           profile,
           id,
         )
+        if (response.status === 404) {
+          json(ctx, 200, { ok: true, already_absent: true })
+          return
+        }
       }
       sendUpstreamResponse(ctx, response, jar)
     })

+ 1 - 1
src/server/workspaceAssets.ts

@@ -85,7 +85,7 @@ export class WorkspaceAssets {
     const agent = this.store.require<WorkspaceAgent>(owner, 'agent', message.agentId)
     await this.archiveText(
       owner,
-      message.content + '\n' + JSON.stringify(message.tools),
+      message.content,
       agent.nodeId,
       agent.profile,
       message.conversationId,

+ 22 - 0
tests/e2e/workspace.spec.ts

@@ -1058,6 +1058,28 @@ test('renders the Kanban snapshot and mobile status control without page overflo
   expect(geometry.board).toBeGreaterThan(geometry.viewport)
 })
 
+test('manages sessions from the sidebar context menu', async ({ page }, testInfo) => {
+  await page.goto('/chat/session-demo?profile=yaoyao')
+  const row = page.locator('.desktop-sidebar [data-sidebar-id="session-second"]')
+  const menu = page.getByRole('menu', { name: '会话操作' })
+  await row.click({ button: 'right' })
+  await menu.getByRole('menuitem', { name: '置顶会话', exact: true }).click()
+  await expect(menu).toBeHidden()
+  await row.click({ button: 'right' })
+  await expect(menu.getByRole('menuitem', { name: '取消置顶', exact: true })).toBeVisible()
+  await expect(menu).toHaveCSS('opacity', '1')
+  await page.screenshot({ path: testInfo.outputPath('sidebar-context-pinned.png') })
+  await menu.getByRole('menuitem', { name: '取消置顶', exact: true }).click()
+  await row.click({ button: 'right' })
+  await expect(menu.getByRole('menuitem', { name: '置顶会话', exact: true })).toBeVisible()
+  await menu.getByRole('menuitem', { name: '删除会话', exact: true }).click()
+  await expect(row).toHaveCount(0)
+  await page.reload()
+  await expect(page.locator('.desktop-sidebar [data-sidebar-id="session-demo"]')).toBeVisible()
+  await expect(row).toHaveCount(0)
+  await page.screenshot({ path: testInfo.outputPath('sidebar-context-deleted.png') })
+})
+
 test('serves cached chat history locally and refreshes upstream only on explicit action', async ({ page }, testInfo) => {
   const messagesURL = (url: string) => url.includes('/api/app/sessions/session-demo/messages')
   const initial = page.waitForResponse(response => messagesURL(response.url()))

+ 4 - 0
tests/fixtures/fake-hermes.mjs

@@ -354,6 +354,10 @@ const server = createServer(async (request, response) => {
   if (/^\/api\/sessions\/[^/]+$/.test(url.pathname)) {
     const id = decodeURIComponent(url.pathname.split('/').at(-1))
     const session = sessions.find(item => item.id === id && (!url.searchParams.get('profile') || item.profile === url.searchParams.get('profile')))
+    if (request.method === 'DELETE') {
+      if (session) sessions.splice(sessions.indexOf(session), 1)
+      return json(response, 200, { ok: true })
+    }
     if (session && request.method === 'PATCH') {
       const input = await body(request)
       if (typeof input.pinned === 'boolean') session.pinned = input.pinned

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

@@ -30,6 +30,82 @@ afterEach(() => {
 })
 
 describe('durable source=web chat cache', () => {
+  it('cleans confirmed missing sessions behind a warm list without pruning filtered or unavailable sessions', async () => {
+    const f = fixture()
+    const ids = ['missing', 'archived', 'unavailable', 'new-session', 'other-profile']
+    for (const id of ids) {
+      const scope = id === 'other-profile' ? 'another' : profile
+      f.store.recordCommand(owner, scope, id, 'session.create', { source: 'web' })
+      f.store.putSnapshot(owner, `detail:${id}`, 'detail', scope, id, response({ id, profile: scope, title: id }))
+    }
+    f.store.db.prepare('UPDATE chat_sessions SET owned_at=? WHERE session_id<>?').run(Date.now() - 120_000, 'new-session')
+    const request = vi.mocked(f.coordinator.upstream.request).mockImplementation(async path => {
+      if (path.endsWith('/archived')) return response({ id: 'archived', archived: true })
+      if (path.endsWith('/unavailable')) return response({}, 503)
+      return response({ detail: 'Session not found' }, 404)
+    })
+    const changed = vi.fn()
+    f.coordinator.onSynchronized = changed
+    const load = vi.fn(async () => response({ sessions: [], total: 0 }))
+    await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
+    await vi.waitFor(() => expect(f.store.ownsSession(owner, profile, 'missing')).toBe(false))
+    expect(f.store.ownsSession(owner, profile, 'archived')).toBe(true)
+    expect(f.store.ownsSession(owner, profile, 'unavailable')).toBe(true)
+    expect(f.store.ownsSession(owner, profile, 'new-session')).toBe(true)
+    expect(f.store.ownsSession(owner, 'another', 'other-profile')).toBe(true)
+    expect(request.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true)
+    expect(changed).toHaveBeenCalledWith(owner, profile, 'missing')
+    await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
+    expect(load).toHaveBeenCalledTimes(1)
+    f.store.close()
+  })
+
+  it('removes a missing session when reconciliation receives a definitive 404', async () => {
+    const f = fixture()
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
+    f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: 'ghost' }))
+    vi.mocked(f.coordinator.upstream.request).mockResolvedValue(response({ detail: 'Session not found' }, 404))
+    await f.coordinator.reconcile(owner, profile, sessionID).catch(() => {})
+    expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
+    expect(f.store.localDetail(owner, profile, sessionID)).toBeUndefined()
+    f.store.close()
+  })
+
+  it('keeps local chats during a list outage and ignores deletion probes superseded by a new turn', async () => {
+    const f = fixture()
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
+    f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: '保留' }))
+    f.store.db.prepare('UPDATE chat_sessions SET owned_at=?').run(Date.now() - 120_000)
+    const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(response({ sessions: [] }))
+    vi.useFakeTimers({ toFake: ['Date'] })
+    try {
+      await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
+      await new Promise(resolve => setTimeout(resolve, 0))
+      expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
+      vi.setSystemTime(Date.now() + 31_000)
+      vi.mocked(f.coordinator.upstream.request).mockImplementation(async () => {
+        f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: 'new turn' })
+        return response({}, 404)
+      })
+      await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
+      await vi.waitFor(() => expect(f.coordinator.upstream.request).toHaveBeenCalled())
+      expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
+    } finally { vi.useRealTimers(); f.store.close() }
+  })
+
+  it('keeps acknowledged flags across aliases and rejects a deleted session sync', () => {
+    const f = fixture()
+    f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
+    f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, is_pinned: false }))
+    const revision = f.store.revision(owner, profile, sessionID)
+    f.store.recordSessionPatch(owner, profile, sessionID, { pinned: true })
+    expect(JSON.parse(f.store.localDetail(owner, profile, sessionID)!.response.body.toString()).pinned).toBe(true)
+    expect(f.store.applySync(owner, profile, sessionID, revision, [], false)).toBe(false)
+    f.store.deleteSession(owner, profile, sessionID)
+    expect(f.store.applySync(owner, profile, sessionID, 0, [], true)).toBe(false)
+    f.store.close()
+  })
+
   it('keeps session metadata when message envelopes only contain session_id and advances activity from messages', () => {
     const f = fixture()
     f.store.recordRoute(owner, profile, sessionID, 'runtime-web')

+ 41 - 0
tests/server/chatCacheRoutes.test.ts

@@ -32,6 +32,47 @@ afterEach(() => {
 })
 
 describe('source=web chat cache routes', () => {
+  it('publishes pin changes immediately and deletes owned sessions already absent upstream', async () => {
+    const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-actions-')); homes.push(home)
+    let missing = false
+    const fetchImpl = vi.fn<typeof fetch>(async (input, init) => {
+      const url = new URL(String(input))
+      if (url.pathname === '/api/status') return Response.json({ auth_required: false })
+      if (url.pathname === '/') return new Response('<script>window.__HERMES_SESSION_TOKEN__="actions-token-1234567890123456";</script>')
+      if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default' }] })
+      if (init?.method === 'PATCH') return Response.json({ ok: true })
+      if (init?.method === 'DELETE') return Response.json({ detail: 'Session not found' }, { status: 404 })
+      if (missing) return Response.json({ detail: 'Session not found' }, { status: 404 })
+      // Reconciliation is unavailable, so the acknowledged mutation must not
+      // depend on a successful metadata/history round trip.
+      if (url.pathname.endsWith('/messages')) return Response.json({}, { status: 503 })
+      return Response.json({ id: 'owned', profile: 'default', title: '测试', pinned: false })
+    })
+    const runtime = createApplication({ config: config(home), fetchImpl }); runtimes.push(runtime)
+    const agent = request.agent(runtime.app.callback())
+    const boot = await agent.get('/api/app/bootstrap').set('Host', host).expect(200)
+    const setup = await agent.post('/api/app/setup').set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', boot.body.csrfToken).send({ username: 'owner', password: 'fixture-password' }).expect(200)
+    const owner = String(setup.body.user.id)
+    runtime.chatCache!.store.recordCommand(owner, 'default', 'owned', 'session.create', { source: 'web' })
+    const path = '/api/app/sessions/owned?profile=default'
+    await agent.get(path).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(200)
+    await agent.patch(path).set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', setup.body.csrfToken).send({ pinned: true }).expect(200)
+    expect((await agent.get(path).set('Host', host).expect(200)).body.pinned).toBe(true)
+    const list = await agent.get('/api/app/sessions?view=chat&profile=default').set('Host', host).expect(200)
+    expect(list.body.sessions[0].pinned).toBe(true)
+    await runtime.chatCache!.reconcile(owner, 'default', 'owned').catch(() => {})
+    missing = true
+    await agent.delete(path).set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', setup.body.csrfToken).expect(200)
+    expect(runtime.chatCache!.store.ownsSession(owner, 'default', 'owned')).toBe(false)
+    const before = fetchImpl.mock.calls.length
+    await agent.delete('/api/app/sessions/foreign?profile=default').set('Host', host).set('Origin', origin)
+      .set('X-CSRF-Token', setup.body.csrfToken).expect(410)
+    expect(fetchImpl.mock.calls).toHaveLength(before)
+  })
+
   it('uses the Web-owned session registry when Hermes source metadata is stale', async () => {
     const home = mkdtempSync(join(tmpdir(), 'yaoyao-owned-chat-routes-'))
     homes.push(home)

+ 29 - 1
tests/server/workspaceRoutes.test.ts

@@ -2,7 +2,7 @@
 import { mkdtempSync, rmSync, readFileSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
-import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
 import request from 'supertest'
 import type Koa from 'koa'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app'
@@ -91,6 +91,34 @@ afterEach(() => {
   rmSync(home, { recursive: true, force: true })
 })
 describe('application workspace HTTP contract', () => {
+  it('archives native chat reply attachments without collecting tool process files', async () => {
+    const emit = runtime.realtime.broker.onNativeEvent
+    emit('first', 'default', 'native-session', { type: 'tool.complete', payload: {
+      output: '![example](/tmp/skill-example.png)', output_path: '/tmp/process.csv',
+    } })
+    emit('first', 'default', 'native-session', { type: 'message.complete', payload: {
+      row_id: 'reply', text: '下载 [报告](/tmp/final-report.pdf)',
+      tools: [{ output_path: '/tmp/internal.csv' }],
+    } })
+    await vi.waitFor(() => expect(runtime.workspace.list('first', 'file')).toHaveLength(1))
+    const page = await req('get', '/api/app/files').expect(200)
+    expect(page.body.items.map((item: { name: string }) => item.name)).toEqual(['final-report.pdf'])
+    expect(page.body.items[0]).toMatchObject({ sender: 'agent', messageId: 'reply' })
+  })
+
+  it('archives workspace reply files without collecting examples in tools', async () => {
+    const agent = (await req('post', '/api/app/agents').send({ name: '附件测试', profile: 'default' }).expect(201)).body.agent
+    const conversation = (await req('get', '/api/app/conversations').expect(200)).body.conversations[0]
+    const assets = new WorkspaceAssets(runtime.workspace, runtime.workspaceRuntime.nodes, home)
+    await assets.archive('first', {
+      id: 'reply', conversationId: conversation.id, agentId: agent.id, role: 'assistant',
+      seq: 1, content: '![结果](/tmp/final.png)', reasoning: '', status: 'complete',
+      attachments: [], tools: [{ name: 'skill_view', output: '![示例](/tmp/example.png)' }], createdAt: Date.now(),
+    })
+    const page = await req('get', '/api/app/files').expect(200)
+    expect(page.body.items.map((item: { name: string }) => item.name)).toEqual(['final.png'])
+  })
+
   it('accepts only child pairing, hides credentials, and scopes address edits to the owner', async () => {
     const code = new URL('yaoyao://pair')
     for (const [key,value] of Object.entries({v:'1',url:'http://child.test:15300',node:'11111111-1111-4111-8111-111111111111',id:'33333333-3333-4333-8333-333333333333',fingerprint:'f'.repeat(64),secret:'s'.repeat(64)})) code.searchParams.set(key,value)