Explorar o código

回退仓库内容至 v0.3.32(9c8fb14)

完整恢复到 9c8fb14038b27013a49079c02aa7b407eadaea88 的文件树,保留已有提交历史。
samien hai 1 mes
pai
achega
5aa8dfbfb8

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 # 夭夭 Web
 
-当前发布版本:**v0.3.33**,配套 iOS **1.3(188)**。详见 [发布说明](docs/releases/v0.3.33.md)。
+当前发布版本:**v0.3.32**,配套 iOS **1.3(188)**。详见 [发布说明](docs/releases/v0.3.32.md)。
 
 夭夭 Web 为网页、iOS 和安卓提供统一的 Agent、群聊、文件库及语音配置服务。Hermes 通过标准 9119 HTTP/WebSocket 接口执行任务。
 

+ 0 - 10
docs/releases/v0.3.33.md

@@ -1,10 +0,0 @@
-# v0.3.33
-
-- 修复聊天列表长期保留失效会话的问题:后台核验已缓存会话,仅在 Hermes 明确返回不存在时清理;保留分页或筛选中未显示、上游暂时不可用及刚创建的会话。
-- 修复侧栏右键菜单的置顶、取消置顶和删除操作。已确认的置顶状态立即写入本地缓存;上游已经删除的自有会话也可以正常清理,刷新后不再出现。
-- 防止会话删除后被旧的历史同步结果恢复,并在事件连接就绪时刷新列表。过期的“不存在”响应不会清理请求期间已有新活动的会话及缓存附件。
-- 调整文件库自动归档范围,保留最终回复与明确附件中的文件,避免把工具输入、技能示例和中间过程文件收进文件库。
-
-本次发布 Web。附件归档规则作用于后续消息,已归档的历史文件不会自动移除。
-
-发布验证:97 个测试文件、642 项测试及 7 项隔离浏览器回归通过;类型检查、版本清单校验、品牌资源校验和生产构建通过。

+ 2 - 2
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.33",
+  "version": "0.3.32",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "hermes-yaoyao",
-      "version": "0.3.33",
+      "version": "0.3.32",
       "license": "BSL-1.1",
       "dependencies": {
         "@koa/bodyparser": "^5.0.0",

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "hermes-yaoyao",
-  "version": "0.3.33",
+  "version": "0.3.32",
   "private": true,
   "license": "BSL-1.1",
   "description": "夭夭 Web:直连 Hermes 9119 的聊天、团队、文件库与产物工作台",

+ 3 - 3
release.json

@@ -1,6 +1,6 @@
 {
   "schemaVersion": 1,
-  "releaseVersion": "0.3.33",
-  "webVersion": "0.3.33",
-  "gitTag": "v0.3.33"
+  "releaseVersion": "0.3.32",
+  "webVersion": "0.3.32",
+  "gitTag": "v0.3.32"
 }

+ 2 - 7
src/server/app.ts

@@ -221,13 +221,8 @@ 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', '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')
+    if (!['message.complete', 'tool.complete', 'tool.completed', 'attachment.staged'].includes(frame.type)) return
+    const data = frame.payload ?? {}, text = JSON.stringify(data)
     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(() => {})
   }

+ 1 - 19
src/server/chatCache.ts

@@ -81,14 +81,6 @@ 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
@@ -385,7 +377,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.ownsSession(owner, profile, sessionID) || this.revision(owner, profile, sessionID) !== revision) return false
+    if (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)
@@ -521,16 +513,6 @@ 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)
-  }
-
   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`)

+ 14 - 7
src/server/routes.ts

@@ -2147,7 +2147,15 @@ 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) {
-        dependencies.chatCache?.store.recordSessionPatch(owner, profile, id, request)
+        if (typeof request.title === 'string') {
+          dependencies.chatCache?.store.recordAuthoritativeTitle(
+            owner,
+            profile,
+            id,
+            request.title,
+          )
+        }
+        dependencies.chatCache?.store.markListsStale(owner)
         void dependencies.chatCache?.reconcile(owner, profile, id).catch(() => {})
       }
       sendUpstreamResponse(ctx, response, jar)
@@ -2159,18 +2167,17 @@ export function createApiRouter(dependencies: RouteDependencies): Router {
     await withJar(ctx, async jar => {
       const profile = search.get('profile') || 'default'
       const owner = dependencies.auth.require(ctx).id
-      if (!dependencies.chatCache?.store.ownsSession(owner, profile, id)) nativeSessionsReadOnly()
+      requireWritableOwnedSession(
+        await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { search }),
+        dependencies.chatCache?.store.ownsSession(owner, profile, id) ?? false,
+      )
       const response = await dependencies.upstream.request(`/api/sessions/${encodeURIComponent(id)}`, jar, { method: 'DELETE', search })
-      if ((response.status >= 200 && response.status < 300) || response.status === 404) {
+      if (response.status >= 200 && response.status < 300) {
         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,
+      message.content + '\n' + JSON.stringify(message.tools),
       agent.nodeId,
       agent.profile,
       message.conversationId,

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

@@ -1058,28 +1058,6 @@ 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()))

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

@@ -354,10 +354,6 @@ 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

+ 1 - 77
tests/server/chatCache.test.ts

@@ -1,5 +1,5 @@
 // @vitest-environment node
-import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
+import { mkdtempSync, rmSync } from 'node:fs'
 import { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { DatabaseSync } from 'node:sqlite'
@@ -30,82 +30,6 @@ afterEach(() => {
 })
 
 describe('durable source=web chat cache', () => {
-  it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
-    'preserves sessions and cached files after repeated read 404s in %s mode', async mode => {
-      const f = fixture()
-      const coordinator = new ChatCacheCoordinator(f.store, f.coordinator.upstream, mode)
-      const sourcePath = '/tmp/final-report.png'
-      try {
-        f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
-        f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({
-          messages: [{ id: 'latest', role: 'assistant', content: `![报告](${sourcePath})` }],
-          pagination: { total: 2, offset: 0, has_more: true },
-        }))
-        f.store.storeAttachment(owner, sourcePath, response('report-bytes', 200, 'image/png'))
-        const asset = f.store.attachment(owner, sourcePath)!
-        const changed = vi.fn()
-        coordinator.onSynchronized = changed
-        const result = await coordinator.messages(owner, 'older', profile, sessionID, 1, 1, async () => {
-          f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: '新消息' })
-          return response({ detail: 'Session not found' }, 404)
-        })
-        expect(result.response.status).toBe(404)
-        expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
-        expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 1)!.response.body.toString()).messages)
-          .toEqual([expect.objectContaining({ id: 'latest' })])
-        expect(readFileSync(asset.localPath, 'utf8')).toBe('report-bytes')
-        expect(changed).not.toHaveBeenCalled()
-
-        await coordinator.messages(owner, 'older', profile, sessionID, 1, 1,
-          async () => response({ detail: 'Session not found' }, 404))
-        expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
-        expect(readFileSync(asset.localPath, 'utf8')).toBe('report-bytes')
-        expect(changed).not.toHaveBeenCalled()
-      } finally { f.store.close() }
-    },
-  )
-
-  it('serves older cached sessions without probing or automatically cleaning them', async () => {
-    const f = fixture()
-    try {
-      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(async () => response({ sessions: [], total: 0 }))
-      vi.mocked(f.coordinator.upstream.request).mockResolvedValue(response({}, 404))
-      const result = await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
-      expect(result.source).toBe('local')
-      expect(JSON.parse(result.response.body.toString()).sessions).toContainEqual(expect.objectContaining({ id: sessionID }))
-      expect(load).not.toHaveBeenCalled()
-      expect(f.coordinator.upstream.request).not.toHaveBeenCalled()
-      expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
-    } finally { f.store.close() }
-  })
-
-  it('retains a cached session when reconciliation receives a 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 expect(f.coordinator.reconcile(owner, profile, sessionID)).rejects.toThrow('Unable to synchronize conversation metadata')
-    expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
-    expect(f.store.localDetail(owner, profile, sessionID)).toBeDefined()
-    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')

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

@@ -32,77 +32,6 @@ afterEach(() => {
 })
 
 describe('source=web chat cache routes', () => {
-  it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
-    'preserves sessions after repeated bypass 404s in %s mode', async chatCacheMode => {
-      const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-stale-bypass-')); homes.push(home)
-      let duringRead = () => {}
-      const fetchImpl = vi.fn<typeof fetch>(async input => {
-        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__="stale-token-1234567890123456";</script>')
-        if (url.pathname === '/api/profiles') return Response.json({ profiles: [{ name: 'default' }] })
-        if (url.pathname === '/api/sessions/owned') duringRead()
-        return Response.json({ detail: 'Session not found' }, { status: 404 })
-      })
-      const runtime = createApplication({ config: { ...config(home), chatCacheMode }, 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)
-      const store = runtime.chatCache!.store
-      store.recordCommand(owner, 'default', 'owned', 'session.create', { source: 'web' })
-      duringRead = () => { store.recordCommand(owner, 'default', 'owned', 'prompt.submit', { text: '新消息' }) }
-      const path = '/api/app/sessions/owned?profile=default'
-      await agent.get(path).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(404)
-      expect(store.ownsSession(owner, 'default', 'owned')).toBe(true)
-      duringRead = () => {}
-      await agent.get(path).set('Host', host).set('X-Yaoyao-Cache', 'bypass').expect(404)
-      expect(store.ownsSession(owner, 'default', 'owned')).toBe(true)
-    },
-  )
-
-  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)

+ 1 - 29
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, vi } from 'vitest'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
 import request from 'supertest'
 import type Koa from 'koa'
 import { createApplication, type ApplicationRuntime } from '../../src/server/app'
@@ -91,34 +91,6 @@ 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)