@allbluecn/web-app 0.23.2 → 0.23.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/package.json +1 -1
  2. package/src/components/ai/global-harness-sidebar/__tests__/global-ai-prompts.test.ts +2 -0
  3. package/src/components/desktop/__tests__/desktop-state-listener.test.tsx +183 -0
  4. package/src/components/desktop/__tests__/sync-indicator.test.tsx +130 -0
  5. package/src/components/desktop/cloud-login.tsx +108 -6
  6. package/src/components/desktop/desktop-state-listener.tsx +76 -0
  7. package/src/components/desktop/shell-widgets.tsx +12 -5
  8. package/src/components/desktop/sync-indicator.tsx +62 -13
  9. package/src/components/desktop-agent/__tests__/agent-chat.test.tsx +194 -0
  10. package/src/components/desktop-agent/__tests__/agent-tool-card.test.tsx +138 -0
  11. package/src/components/desktop-agent/__tests__/agent-workspace-card.test.tsx +189 -0
  12. package/src/components/desktop-agent/agent-chat.tsx +221 -0
  13. package/src/components/desktop-agent/agent-tool-card.tsx +185 -0
  14. package/src/components/desktop-agent/agent-workspace-card.tsx +176 -0
  15. package/src/components/layout/sidebar-03/__tests__/user-menu-anonymous.test.tsx +4 -0
  16. package/src/components/layout/sidebar-03/app-sidebar.tsx +9 -2
  17. package/src/components/layout/sidebar-03/user-menu.tsx +63 -0
  18. package/src/config/sidebar-routes.tsx +14 -1
  19. package/src/lib/__tests__/session.test.ts +95 -0
  20. package/src/lib/auth/auth.config.ts +13 -0
  21. package/src/lib/changelog/sdk-versions.ts +1 -1
  22. package/src/lib/desktop-agent/__tests__/connection.test.ts +223 -0
  23. package/src/lib/desktop-agent/__tests__/errors.test.ts +43 -0
  24. package/src/lib/desktop-agent/__tests__/tool-result.test.ts +163 -0
  25. package/src/lib/desktop-agent/__tests__/use-desktop-agent-presence.test.tsx +181 -0
  26. package/src/lib/desktop-agent/availability.ts +39 -0
  27. package/src/lib/desktop-agent/connection.ts +72 -0
  28. package/src/lib/desktop-agent/errors.ts +21 -0
  29. package/src/lib/desktop-agent/tool-result.ts +170 -0
  30. package/src/lib/desktop-agent/use-desktop-agent-presence.ts +64 -0
  31. package/src/lib/desktop-bridge-types.ts +58 -1
  32. package/src/lib/hotkeys/__tests__/registry.test.ts +11 -4
  33. package/src/lib/hotkeys/registry.ts +9 -1
  34. package/src/lib/queries/README.md +1 -0
  35. package/src/lib/queries/__tests__/desktop-agent-queries.test.ts +80 -0
  36. package/src/lib/queries/desktop-agent.ts +25 -0
  37. package/src/lib/session.ts +15 -1
  38. package/src/paraglide/messages/_index.js +820 -0
  39. package/src/paraglide/messages/en.js +260 -0
  40. package/src/paraglide/messages/zh.js +260 -0
  41. package/src/routeTree.gen.ts +23 -0
  42. package/src/routes/-tests/agent-gate.test.ts +45 -0
  43. package/src/routes/_nav/agent/route.lazy.tsx +63 -0
  44. package/src/routes/_nav/agent/route.tsx +21 -0
  45. package/src/routes/api/sync/account-status.ts +5 -21
  46. package/src/server/__tests__/exchange-remote-token.test.ts +113 -0
  47. package/src/server/__tests__/remote-auth.test.ts +131 -1
  48. package/src/server/desktop-bootstrap.ts +19 -3
  49. package/src/server/desktop-session-merge.ts +51 -0
  50. package/src/server/remote-auth.ts +61 -3
  51. package/src/server/serverFns/auth/session.ts +28 -28
  52. package/src/server/serverFns/sync/transfer.ts +168 -0
  53. package/src/server/serverfn-public-allowlist.ts +4 -0
  54. package/src/server/sync/__tests__/ownership-transfer.test.ts +896 -0
  55. package/src/server/sync/account-status.ts +36 -0
  56. package/src/server/sync/ownership-transfer.ts +291 -0
  57. package/src/server/sync/saas-profile.ts +70 -0
@@ -23,7 +23,11 @@ import {
23
23
  Settings,
24
24
  Users,
25
25
  } from "lucide-react";
26
+ import type { QueryClient } from "@tanstack/react-query";
26
27
  import { logoutRemoteFn } from '@/server/serverFns/auth/session'
28
+ import { reverseTransferOwnershipFn } from '@/server/serverFns/sync/transfer'
29
+ import { getDesktopBridge } from '@/lib/desktop-bridge-types'
30
+ import type { DesktopBridgeApi } from '@/lib/desktop-bridge-types'
27
31
  import { toast } from '@/components/ui/toast'
28
32
 
29
33
  import {
@@ -49,6 +53,56 @@ import type { AvatarConfig } from "@allbluecn/kernel";
49
53
  import { SparklesText } from "@/components/magicui/sparkles-text";
50
54
  import { m } from "@/paraglide/messages.js";
51
55
 
56
+ /**
57
+ * 桌面云态登出(桌面本地优先 P2-A9 反向转移编排,裁定顺序):登出 = 把云身份名下
58
+ * 数据翻回 guest(数据保留本机),双会话清理后回到 guest 态。
59
+ * ① 置转移锁(transferLock,复用 bind 锁文件——引擎每 tick 判定同源,「转移进行中
60
+ * 引擎勿 tick」;旧壳无此 IPC 优雅降级:反向转移单事务自身原子,锁为双保险);
61
+ * ② reverseTransferOwnershipFn(serverFn:校验 remoteAuth/会话本人 → 单事务
62
+ * cloud→guest + outbox/row_state 同事务清空防反向 op 上云 + 重建 guest 行;
63
+ * 事务提交后 server 侧已清 remote-auth——崩窗口收敛,A8b 发现 2 的镜像);
64
+ * ③ 双会话清理:logoutRemoteFn(幂等 re-clear remote-auth)+ clearCloudSession
65
+ * (清 cloud-session.json,引擎 guest 守卫回 idle)→ 清锁(先清云会话再解锁,
66
+ * 引擎恢复 tick 前云会话已不可用);
67
+ * ④ queryClient.clear() + 整页 reload——reload 后 getSessionFn 回落 guest 会话。
68
+ * 失败路径(锁失败 / 转移失败):解锁 + toast sync_logout_failed + 不 reload(留在
69
+ * 云态可重试;转移失败单事务已回滚,数据仍是云属主,重试幂等)。模块内局部函数
70
+ * (非导出):本文件唯一导出是 UserMenu 组件,勿破坏 Fast Refresh 纯组件边界。
71
+ */
72
+ async function desktopCloudLogout(
73
+ bridge: DesktopBridgeApi,
74
+ cloudUserId: string,
75
+ queryClient: QueryClient,
76
+ ): Promise<void> {
77
+ toast.info(m.sync_logout_in_progress());
78
+ // ① 转移锁
79
+ let lock: { ok: boolean; error?: string } = { ok: true };
80
+ try {
81
+ lock = bridge.transferLock ? await bridge.transferLock("begin") : { ok: true };
82
+ } catch {
83
+ lock = { ok: false };
84
+ }
85
+ if (!lock.ok) {
86
+ toast.error(m.sync_logout_failed());
87
+ return;
88
+ }
89
+ // ② 反向转移(失败:单事务回滚留在云态,解锁可重试)
90
+ try {
91
+ await reverseTransferOwnershipFn({ data: { cloudUserId } });
92
+ } catch {
93
+ try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
94
+ toast.error(m.sync_logout_failed());
95
+ return;
96
+ }
97
+ // ③ 双会话清理(均幂等 best-effort:serverFn 已清 remote-auth,此处兜底)
98
+ try { await logoutRemoteFn({ data: {} }); } catch { /* 尽力而为 */ }
99
+ try { await bridge.clearCloudSession?.(); } catch { /* 尽力而为 */ }
100
+ try { await bridge.transferLock?.("end"); } catch { /* 尽力而为 */ }
101
+ // ④ 缓存清空 + 整页刷新(reload 后 desktop-state 回 guest)
102
+ queryClient.clear();
103
+ window.location.reload();
104
+ }
105
+
52
106
  export function UserMenu() {
53
107
  const { isMobile } = useSidebar();
54
108
  const { data: clientSession } = useSession();
@@ -83,6 +137,15 @@ export function UserMenu() {
83
137
  window.location.reload();
84
138
  return;
85
139
  }
140
+ // 桌面云态(A9):桌面壳存在且无本地 authjs 会话(clientSession 为空)=当前
141
+ // 会话是 remote-auth 回落的轻量云身份,登出走反向转移编排(数据翻回 guest)。
142
+ // 本地凭据登录(clientSession 非空)与 web 照旧走 signOut 链——云身份回落仅在
143
+ // authjs 会话无效时生效,clientSession 为空是云态判别式。
144
+ const bridge = getDesktopBridge();
145
+ if (bridge && !clientSession && user?.id) {
146
+ await desktopCloudLogout(bridge, user.id, queryClient);
147
+ return;
148
+ }
86
149
  await signOut();
87
150
  queryClient.resetQueries({ queryKey: ['session'] });
88
151
  await router.navigate({ to: "/login" });
@@ -26,6 +26,7 @@ import {
26
26
  Bug,
27
27
  PenTool,
28
28
  ListTodo,
29
+ FolderSearch,
29
30
  } from "lucide-react";
30
31
 
31
32
  export type RouteSubItem = {
@@ -100,4 +101,16 @@ export const workspaceRoutes: Route[] = [
100
101
  icon: <ListTodo className="size-4" />,
101
102
  link: "/tasks",
102
103
  },
103
- ];
104
+ ];
105
+
106
+ /**
107
+ * 本地文件助手(桌面本地优先 P3-B5):依赖桌面壳 AgentService,不进上方常驻列表——
108
+ * app-sidebar 经 useDesktopAgentPresence(桌面构建 + desktopBridge,mounted 模式)
109
+ * 判定后条件追加,云 Web / OSS 恒不可见(与 /_nav/agent 路由门控同源)。
110
+ */
111
+ export const desktopAgentRoute: Route = {
112
+ id: "desktop-agent",
113
+ title: m.agent_desktop_title,
114
+ icon: <FolderSearch className="size-4" />,
115
+ link: "/agent",
116
+ };
@@ -0,0 +1,95 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // getCurrentSession 桌面回落单点测试(桌面本地优先 P2 / A10)。
6
+ // 优先级链钉死(复审③ B1/B2):authjs session(有效则返回)→ desktop 时
7
+ // remoteAuth → null。start-authjs / auth.config / remote-auth 全 mock:
8
+ // 不触真实 JWT 解密、不真读写 DESKTOP_USER_DATA_DIR 下的 remote-auth.json
9
+ // (#578 fs 实现勿真写盘)。desktop 判定走 DEPLOY_TARGET env stub
10
+ // (isDesktopServer 调用时读 env,见 desktop-guest.ts:40)。
11
+ import { afterEach, describe, expect, it, vi } from 'vitest'
12
+ import { getCurrentSession } from '../session'
13
+ import type { Session } from '../session'
14
+
15
+ const { getSessionMock, getAuthConfigMock, readRemoteAuthMock } = vi.hoisted(() => ({
16
+ getSessionMock: vi.fn(),
17
+ getAuthConfigMock: vi.fn(async () => ({})),
18
+ readRemoteAuthMock: vi.fn(),
19
+ }))
20
+
21
+ vi.mock('start-authjs', () => ({
22
+ getSession: getSessionMock,
23
+ }))
24
+
25
+ vi.mock('@/lib/auth/auth.config', () => ({
26
+ getAuthConfig: getAuthConfigMock,
27
+ }))
28
+
29
+ vi.mock('@/server/remote-auth', () => ({
30
+ readRemoteAuth: readRemoteAuthMock,
31
+ }))
32
+
33
+ const AUTHED: Session = {
34
+ user: { id: 'authjs-user-1', name: 'AuthJS 用户' },
35
+ expires: '2026-01-01T00:00:00.000Z',
36
+ }
37
+
38
+ const REMOTE_AUTH = {
39
+ userId: 'cloud-user-abc',
40
+ planName: 'pro',
41
+ features: {},
42
+ sessionToken: 'cloud-session-token',
43
+ }
44
+
45
+ const REQ = () => new Request('http://localhost/api/auth/session')
46
+
47
+ describe('getCurrentSession 桌面回落(A10 优先级链)', () => {
48
+ afterEach(() => {
49
+ vi.unstubAllEnvs()
50
+ vi.clearAllMocks()
51
+ })
52
+
53
+ it('①a authjs session 有效 → 直接返回,desktop 下也不读 remoteAuth', async () => {
54
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
55
+ getSessionMock.mockResolvedValue(AUTHED)
56
+ const session = await getCurrentSession(REQ())
57
+ expect(session?.user?.id).toBe('authjs-user-1')
58
+ expect(readRemoteAuthMock).not.toHaveBeenCalled()
59
+ })
60
+
61
+ it('①b+② authjs 无效 + desktop + remoteAuth 有 → 组装轻量云身份(user.id=cloudUserId)', async () => {
62
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
63
+ getSessionMock.mockResolvedValue(null)
64
+ readRemoteAuthMock.mockResolvedValue(REMOTE_AUTH)
65
+ const session = await getCurrentSession(REQ())
66
+ // 审查重点 4 前半:回落 session 的属主必须是云 userId(非 guest / 非 authjs 残影)
67
+ expect(session?.user?.id).toBe('cloud-user-abc')
68
+ // 组装形态与 buildRemoteSession 对齐(RemoteAuthData 实际字段,不虚构)
69
+ expect(session?.user?.name).toBe('cloud-user-abc') // username 缺省回落 userId
70
+ expect(session?.user?.isGuest).toBe(false)
71
+ expect(session?.user?.hasAiKey).toBe(false)
72
+ expect(session?.expires).toBe('never')
73
+ })
74
+
75
+ it('①c authjs 无效 + desktop + remoteAuth 无 → null', async () => {
76
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
77
+ getSessionMock.mockResolvedValue(null)
78
+ readRemoteAuthMock.mockResolvedValue(null)
79
+ expect(await getCurrentSession(REQ())).toBeNull()
80
+ })
81
+
82
+ it('③ 非 desktop 不读 remoteAuth(env 判定先于 fs 读取)', async () => {
83
+ vi.stubEnv('DEPLOY_TARGET', '')
84
+ getSessionMock.mockResolvedValue(null)
85
+ expect(await getCurrentSession(REQ())).toBeNull()
86
+ expect(readRemoteAuthMock).not.toHaveBeenCalled()
87
+ })
88
+
89
+ it('无 request → null(既有守卫不回归,不触发任何读取)', async () => {
90
+ vi.stubEnv('DEPLOY_TARGET', 'desktop')
91
+ expect(await getCurrentSession()).toBeNull()
92
+ expect(getSessionMock).not.toHaveBeenCalled()
93
+ expect(readRemoteAuthMock).not.toHaveBeenCalled()
94
+ })
95
+ })
@@ -74,6 +74,19 @@ export function invalidateProfileCache(userId: string): void {
74
74
  profileCache.delete(userId)
75
75
  }
76
76
 
77
+ /**
78
+ * 全量清空 profile 缓存(桌面本地优先 A11 缓存三件套之一):身份切换(guest↔
79
+ * cloud)后旧身份的缓存条目必须整体失效——按 userId 逐个 invalidate 需要知道旧
80
+ * id,身份切换场景里调用方(renderer 经 resetAuthProfileCacheFn)恰恰没有可靠
81
+ * 的旧 id,全量 clear 是唯一正确形态。服务端进程内存缓存,清空零风险(miss 后
82
+ * 自然回源)。桌面 guest/remote-auth 回落会话不经过本缓存(buildGuestSession /
83
+ * buildRemoteSession 不查 profileCache),本清理覆盖的是「本地凭据 authjs 会话
84
+ * 与桌面身份切换并存」的混合场景(getCachedProfile(token.id) 消费面)。
85
+ */
86
+ export function resetProfileCache(): void {
87
+ profileCache.clear()
88
+ }
89
+
77
90
  async function queryProfile(userId: string): Promise<CachedProfile | null> {
78
91
  const [dbUser, providerConfigs, sub] = await Promise.all([
79
92
  prisma.user.findUnique({
@@ -112,7 +112,7 @@ export const SDK_GROUPS: SdkGroup[] = [
112
112
  "items": [
113
113
  {
114
114
  "name": "@allbluecn/web-app",
115
- "version": "v0.23.2",
115
+ "version": "v0.23.4",
116
116
  "lastUpdate": "Nov 1, 2026",
117
117
  "source": "apps/web/package.json",
118
118
  "docsUrl": "https://github.com/allbluecn/allblue"
@@ -0,0 +1,223 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // desktop-agent 连接(桌面本地优先 P3-B5):appInfo 调用时懒取 agentPort/agentToken
6
+ // → 真实 fetchServerSentEvents 直连 http://127.0.0.1:{port}/prompt。fetch 打桩、
7
+ // SSE 解析走真实 @tanstack/ai-client 实现——请求线格式即与 Pro 端点的跨仓契约:
8
+ // URL / X-Desktop-Token / X-Run-Id 头 / forwardedProps.cwd(端点 extractCwd 取值位)。
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
10
+ import type { UIMessage } from '@tanstack/ai-client'
11
+
12
+ type AppInfo = {
13
+ version: string
14
+ clientId: string
15
+ platform: string
16
+ backendPort: number | null
17
+ agentPort?: number | null
18
+ agentToken?: string | null
19
+ }
20
+
21
+ const bridgeRef: { current: { appInfo: () => Promise<AppInfo> } | null } = { current: null }
22
+
23
+ vi.mock('@/lib/desktop-bridge-types', () => ({
24
+ getDesktopBridge: () => bridgeRef.current,
25
+ }))
26
+
27
+ import {
28
+ DesktopAgentUnavailableError,
29
+ createDesktopAgentConnection,
30
+ resolveDesktopAgentEndpoint,
31
+ } from '../connection'
32
+
33
+ const BASE_INFO: AppInfo = { version: '1.0.0', clientId: 'c1', platform: 'darwin', backendPort: 51000 }
34
+
35
+ function bridgeWith(info: Partial<AppInfo>) {
36
+ return { appInfo: vi.fn(async () => ({ ...BASE_INFO, ...info })) }
37
+ }
38
+
39
+ const encoder = new TextEncoder()
40
+ function sse(event: Record<string, unknown>): Uint8Array {
41
+ return encoder.encode(`data: ${JSON.stringify(event)}\n\n`)
42
+ }
43
+
44
+ /** 打桩 fetch:返回给定 AG-UI 事件序列的 SSE 响应 */
45
+ function stubSseFetch(events: Array<Record<string, unknown>>) {
46
+ const fetchMock = vi.fn(async (_url: string | URL | RequestInfo, _init?: RequestInit) => {
47
+ const stream = new ReadableStream<Uint8Array>({
48
+ start(c) {
49
+ for (const e of events) c.enqueue(sse(e))
50
+ c.close()
51
+ },
52
+ })
53
+ return new Response(stream, { status: 200, headers: { 'content-type': 'text/event-stream' } })
54
+ })
55
+ vi.stubGlobal('fetch', fetchMock)
56
+ return fetchMock
57
+ }
58
+
59
+ const RUN_EVENTS = [
60
+ { type: 'RUN_STARTED', threadId: 't-1', runId: 'r-1' },
61
+ { type: 'TEXT_MESSAGE_START', messageId: 'msg-1', role: 'assistant' },
62
+ { type: 'TEXT_MESSAGE_CONTENT', messageId: 'msg-1', delta: '目录里有 a.txt' },
63
+ { type: 'TEXT_MESSAGE_END', messageId: 'msg-1' },
64
+ { type: 'RUN_FINISHED', threadId: 't-1', runId: 'r-1' },
65
+ ]
66
+
67
+ const USER_MESSAGE: UIMessage = { id: 'u1', role: 'user', parts: [{ type: 'text', content: '列出目录' }] }
68
+
69
+ async function drain(iterable: AsyncIterable<unknown>): Promise<unknown[]> {
70
+ const out: unknown[] = []
71
+ for await (const chunk of iterable) out.push(chunk)
72
+ return out
73
+ }
74
+
75
+ beforeEach(() => {
76
+ bridgeRef.current = null
77
+ })
78
+
79
+ afterEach(() => {
80
+ vi.unstubAllGlobals()
81
+ })
82
+
83
+ describe('resolveDesktopAgentEndpoint(appInfo 懒取)', () => {
84
+ it('无桥(云 Web / OSS)→ null', async () => {
85
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
86
+ })
87
+
88
+ it('agent 未装配(agentPort/agentToken 为 null 或旧壳 undefined)→ null', async () => {
89
+ bridgeRef.current = bridgeWith({ agentPort: null, agentToken: null })
90
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
91
+ bridgeRef.current = bridgeWith({})
92
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
93
+ })
94
+
95
+ it('端口或令牌非法(越界 / 非整数 / 空令牌)→ null,不拼出坏 URL', async () => {
96
+ for (const agentPort of [0, 65536, 1.5, -1]) {
97
+ bridgeRef.current = bridgeWith({ agentPort, agentToken: 'tok' })
98
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
99
+ }
100
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: '' })
101
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
102
+ })
103
+
104
+ it('就绪 → 127.0.0.1 端点 URL + 令牌', async () => {
105
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
106
+ await expect(resolveDesktopAgentEndpoint()).resolves.toEqual({
107
+ url: 'http://127.0.0.1:4567/prompt',
108
+ token: 'tok-abc',
109
+ })
110
+ })
111
+
112
+ it('appInfo 抛错(IPC 断开 / main 未就绪)→ null,不流出原生错误原文', async () => {
113
+ bridgeRef.current = {
114
+ appInfo: vi.fn(async () => {
115
+ throw new Error('ipc closed')
116
+ }),
117
+ }
118
+ await expect(resolveDesktopAgentEndpoint()).resolves.toBeNull()
119
+ })
120
+ })
121
+
122
+ describe('createDesktopAgentConnection(fetchServerSentEvents 包装)', () => {
123
+ it('端点不可用 → 抛 DesktopAgentUnavailableError,且不发起 fetch', async () => {
124
+ const fetchMock = stubSseFetch(RUN_EVENTS)
125
+ bridgeRef.current = bridgeWith({ agentPort: null, agentToken: null })
126
+ const connection = createDesktopAgentConnection()
127
+ await expect(
128
+ drain(connection.connect([USER_MESSAGE], undefined, undefined, { threadId: 't-1', runId: 'r-1' })),
129
+ ).rejects.toBeInstanceOf(DesktopAgentUnavailableError)
130
+ expect(fetchMock).not.toHaveBeenCalled()
131
+ })
132
+
133
+ it('appInfo 抛错 → 同样归「服务未就绪」(DesktopAgentUnavailableError),不发 fetch、不透出 IPC 原文', async () => {
134
+ const fetchMock = stubSseFetch(RUN_EVENTS)
135
+ bridgeRef.current = {
136
+ appInfo: vi.fn(async () => {
137
+ throw new Error('ipc closed')
138
+ }),
139
+ }
140
+ const error = await drain(
141
+ createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't', runId: 'r' }),
142
+ ).catch((cause: unknown) => cause)
143
+ expect(error).toBeInstanceOf(DesktopAgentUnavailableError)
144
+ expect(fetchMock).not.toHaveBeenCalled()
145
+ })
146
+
147
+ it('请求线格式:URL / X-Desktop-Token / X-Run-Id / forwardedProps.cwd 与 Pro 端点契约一致', async () => {
148
+ const fetchMock = stubSseFetch(RUN_EVENTS)
149
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
150
+ const connection = createDesktopAgentConnection()
151
+ await drain(
152
+ connection.connect([USER_MESSAGE], undefined, undefined, {
153
+ threadId: 't-1',
154
+ runId: 'r-1',
155
+ forwardedProps: { cwd: '/Users/x/proj' },
156
+ }),
157
+ )
158
+ expect(fetchMock).toHaveBeenCalledTimes(1)
159
+ const [url, init] = fetchMock.mock.calls[0]!
160
+ expect(url).toBe('http://127.0.0.1:4567/prompt')
161
+ expect(init?.method).toBe('POST')
162
+ const headers = init?.headers as Record<string, string>
163
+ expect(headers['X-Desktop-Token']).toBe('tok-abc')
164
+ expect(headers['X-Run-Id']).toBe('r-1')
165
+ expect(headers['Content-Type']).toBe('application/json')
166
+ const body = JSON.parse(String(init?.body)) as Record<string, unknown>
167
+ // 端点 extractCwd:顶层无 cwd,取 forwardedProps.cwd(fetchServerSentEvents 真实形态)
168
+ expect(body.cwd).toBeUndefined()
169
+ expect(body.forwardedProps).toEqual({ cwd: '/Users/x/proj' })
170
+ expect(body.threadId).toBe('t-1')
171
+ expect(body.runId).toBe('r-1')
172
+ // 端点 extractPrompt:messages 末条 user 消息文本(纯文本 content 为 string)
173
+ expect(body.messages).toEqual([expect.objectContaining({ role: 'user', content: '列出目录' })])
174
+ })
175
+
176
+ it('SSE 帧原样产出为 AG-UI chunk(RUN_STARTED 起、RUN_FINISHED 止)', async () => {
177
+ stubSseFetch(RUN_EVENTS)
178
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
179
+ const chunks = (await drain(
180
+ createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, {
181
+ threadId: 't-1',
182
+ runId: 'r-1',
183
+ }),
184
+ )) as Array<{ type: string }>
185
+ expect(chunks.map((c) => c.type)).toEqual([
186
+ 'RUN_STARTED',
187
+ 'TEXT_MESSAGE_START',
188
+ 'TEXT_MESSAGE_CONTENT',
189
+ 'TEXT_MESSAGE_END',
190
+ 'RUN_FINISHED',
191
+ ])
192
+ })
193
+
194
+ it('每次 connect 都重取 appInfo(backend 重启换端口/令牌后下一问即用新值)', async () => {
195
+ const fetchMock = stubSseFetch(RUN_EVENTS)
196
+ const appInfo = vi
197
+ .fn<() => Promise<AppInfo>>()
198
+ .mockResolvedValueOnce({ ...BASE_INFO, agentPort: 4567, agentToken: 'tok-old' })
199
+ .mockResolvedValueOnce({ ...BASE_INFO, agentPort: 5678, agentToken: 'tok-new' })
200
+ bridgeRef.current = { appInfo }
201
+ const connection = createDesktopAgentConnection()
202
+ const ctx = { threadId: 't-1', runId: 'r-1' }
203
+ await drain(connection.connect([USER_MESSAGE], undefined, undefined, ctx))
204
+ await drain(connection.connect([USER_MESSAGE], undefined, undefined, ctx))
205
+ expect(appInfo).toHaveBeenCalledTimes(2)
206
+ expect(fetchMock.mock.calls.map((c) => c[0])).toEqual([
207
+ 'http://127.0.0.1:4567/prompt',
208
+ 'http://127.0.0.1:5678/prompt',
209
+ ])
210
+ expect((fetchMock.mock.calls[1]![1]?.headers as Record<string, string>)['X-Desktop-Token']).toBe('tok-new')
211
+ })
212
+
213
+ it('端点非 2xx(如 401 令牌失效)→ 错误携带 status 透传给 useChat error 态', async () => {
214
+ vi.stubGlobal(
215
+ 'fetch',
216
+ vi.fn(async () => new Response('{"error":"bad token"}', { status: 401, statusText: 'Unauthorized' })),
217
+ )
218
+ bridgeRef.current = bridgeWith({ agentPort: 4567, agentToken: 'tok-abc' })
219
+ await expect(
220
+ drain(createDesktopAgentConnection().connect([USER_MESSAGE], undefined, undefined, { threadId: 't', runId: 'r' })),
221
+ ).rejects.toMatchObject({ status: 401 })
222
+ })
223
+ })
@@ -0,0 +1,43 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // desktop-agent 错误文案映射(桌面本地优先 P3-B5):useChat error 态 → 可读 i18n 文案。
6
+ // 错误来源三类:连接包装(DesktopAgentUnavailableError)、端点非 2xx(官方
7
+ // StreamHttpError 携带 status:401 令牌失效 / 403 目录未授权或 origin 被拒)、
8
+ // 网络层(StreamReadError:端口不通 / CORS 预检被拒),其余(RUN_ERROR 等)透出原文。
9
+ import { describe, expect, it } from 'vitest'
10
+ import { m } from '@/paraglide/messages.js'
11
+ import { DesktopAgentUnavailableError } from '../connection'
12
+ import { describeDesktopAgentError } from '../errors'
13
+
14
+ function withStatus(status: number): Error {
15
+ return Object.assign(new Error(`HTTP error! status: ${status}`), { name: 'StreamHttpError', status })
16
+ }
17
+
18
+ describe('describeDesktopAgentError', () => {
19
+ it('端点不可用 → 未就绪提示(实例与同名错误两种形态)', () => {
20
+ expect(describeDesktopAgentError(new DesktopAgentUnavailableError())).toBe(m.agent_desktop_errorUnavailable())
21
+ const renamed = Object.assign(new Error('x'), { name: 'DesktopAgentUnavailableError' })
22
+ expect(describeDesktopAgentError(renamed)).toBe(m.agent_desktop_errorUnavailable())
23
+ })
24
+
25
+ it('401 → 令牌失效提示;403 → 未授权/来源被拒提示', () => {
26
+ expect(describeDesktopAgentError(withStatus(401))).toBe(m.agent_desktop_errorUnauthorized())
27
+ expect(describeDesktopAgentError(withStatus(403))).toBe(m.agent_desktop_errorForbidden())
28
+ })
29
+
30
+ it('网络层失败(StreamReadError:端口不通 / CORS)→ 无法连接提示', () => {
31
+ const err = Object.assign(new Error('Failed to fetch'), { name: 'StreamReadError' })
32
+ expect(describeDesktopAgentError(err)).toBe(m.agent_desktop_errorConnection())
33
+ })
34
+
35
+ it('其余(RUN_ERROR、400、500 等)→ 运行失败 + 原文', () => {
36
+ expect(describeDesktopAgentError(new Error('模型配置缺失'))).toBe(
37
+ m.agent_desktop_errorGeneric({ message: '模型配置缺失' }),
38
+ )
39
+ expect(describeDesktopAgentError(withStatus(500))).toBe(
40
+ m.agent_desktop_errorGeneric({ message: 'HTTP error! status: 500' }),
41
+ )
42
+ })
43
+ })
@@ -0,0 +1,163 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // AllBlue - 理想之海
3
+ // Copyright (C) 2026 AllBlue Contributors
4
+
5
+ // desktop-agent 工具结果解析(桌面本地优先 P3-B5):TOOL_CALL_RESULT.content =
6
+ // JSON.stringify(pi AgentToolResult)(B3 mapper),TanStack processor JSON.parse 后
7
+ // 挂到 tool-call part.output。本测试按 pi 1.4 只读四件套的真实文本格式取证——
8
+ // grep `path:N: text`(上下文行 `path-N- text`)、ls 目录带 `/`、find 相对路径、
9
+ // read 原文;尾部 `\n\n[...limit reached / Use offset=...]` 截断提示;空结果哨兵。
10
+ import { describe, expect, it } from 'vitest'
11
+ import {
12
+ AGENT_TOOL_MAX_ENTRIES,
13
+ extractAgentToolText,
14
+ parseAgentToolArgs,
15
+ summarizeAgentTool,
16
+ } from '../tool-result'
17
+
18
+ const piResult = (text: string) => ({ content: [{ type: 'text', text }], details: undefined })
19
+
20
+ describe('extractAgentToolText', () => {
21
+ it('pi AgentToolResult 对象(processor 已 JSON.parse)→ 文本块拼接', () => {
22
+ expect(extractAgentToolText(piResult('a.txt\nsrc/'))).toBe('a.txt\nsrc/')
23
+ expect(
24
+ extractAgentToolText({ content: [{ type: 'text', text: 'L1' }, { type: 'image', data: 'x' }, { type: 'text', text: 'L2' }] }),
25
+ ).toBe('L1\nL2')
26
+ })
27
+
28
+ it('output 缺席时回落 tool-result part 内容(字符串或 ContentPart[] 内嵌 JSON)', () => {
29
+ const json = JSON.stringify(piResult('hello'))
30
+ expect(extractAgentToolText(undefined, json)).toBe('hello')
31
+ expect(extractAgentToolText(undefined, [{ type: 'text', content: json }])).toBe('hello')
32
+ })
33
+
34
+ it('非 JSON 文本 / processor 错误对象 / 空值', () => {
35
+ expect(extractAgentToolText('plain text')).toBe('plain text')
36
+ expect(extractAgentToolText({ error: '读取失败' })).toBe('读取失败')
37
+ expect(extractAgentToolText(undefined, undefined)).toBeNull()
38
+ expect(extractAgentToolText({ unrelated: true })).toBeNull()
39
+ })
40
+
41
+ it('无 text 块的对象(read 图片:仅 base64 image 块)→ null,不回落原始 JSON 串', () => {
42
+ const base64 = 'iVBORw0KGgoAAAANSUhEUg=='
43
+ const imageOnly = { content: [{ type: 'image', mimeType: 'image/png', data: base64 }], details: undefined }
44
+ // processor 已解析的对象形态
45
+ expect(extractAgentToolText(imageOnly)).toBeNull()
46
+ // tool-result part 回落形态:JSON 串 / ContentPart[] 内嵌 JSON 串
47
+ expect(extractAgentToolText(undefined, JSON.stringify(imageOnly))).toBeNull()
48
+ expect(extractAgentToolText(undefined, [{ type: 'text', content: JSON.stringify(imageOnly) }])).toBeNull()
49
+ // 摘要侧同样无条目(不会把 base64 铺进折叠卡)
50
+ expect(summarizeAgentTool('read', { path: 'logo.png' }, extractAgentToolText(imageOnly))).toMatchObject({
51
+ entries: [],
52
+ total: 0,
53
+ })
54
+ })
55
+ })
56
+
57
+ describe('parseAgentToolArgs', () => {
58
+ it('优先已解析 input;否则解析 arguments 字符串;流式不完整 JSON → 空对象', () => {
59
+ expect(parseAgentToolArgs({ input: { path: 'src' }, arguments: '{"path":"x"}' })).toEqual({ path: 'src' })
60
+ expect(parseAgentToolArgs({ arguments: '{"pattern":"TODO"}' })).toEqual({ pattern: 'TODO' })
61
+ expect(parseAgentToolArgs({ arguments: '{"pattern":"TO' })).toEqual({})
62
+ expect(parseAgentToolArgs({ arguments: '' })).toEqual({})
63
+ expect(parseAgentToolArgs({ arguments: '[1,2]' })).toEqual({})
64
+ })
65
+ })
66
+
67
+ describe('summarizeAgentTool', () => {
68
+ it('grep:命中行解析为 文件/行号/内容,上下文行与截断提示不计入,提示置 truncated', () => {
69
+ const text = [
70
+ 'src/a.ts:12: const TODO = 1',
71
+ 'src/a.ts-13- context line',
72
+ 'docs/b.md:3: TODO: write docs',
73
+ '',
74
+ '[100 matches limit reached. Use limit=200 for more, or refine pattern]',
75
+ ].join('\n')
76
+ const s = summarizeAgentTool('grep', { pattern: 'TODO' }, text)
77
+ expect(s.kind).toBe('grep')
78
+ expect(s.target).toBe('TODO')
79
+ expect(s.entries).toEqual([
80
+ { kind: 'hit', path: 'src/a.ts', line: 12, text: 'const TODO = 1' },
81
+ { kind: 'hit', path: 'docs/b.md', line: 3, text: 'TODO: write docs' },
82
+ ])
83
+ expect(s.total).toBe(2)
84
+ expect(s.truncated).toBe(true)
85
+ expect(s.empty).toBe(false)
86
+ })
87
+
88
+ it('grep:路径含冒号 / 内容含 `:N: ` 均不误切(取首个 `:行号: ` 边界)', () => {
89
+ const s = summarizeAgentTool(
90
+ 'grep',
91
+ { pattern: 'x' },
92
+ 'dir:with:colon/a.ts:7: x = 1\nsrc/log.ts:9: msg("at a.ts:3: boom")',
93
+ )
94
+ expect(s.entries).toEqual([
95
+ { kind: 'hit', path: 'dir:with:colon/a.ts', line: 7, text: 'x = 1' },
96
+ { kind: 'hit', path: 'src/log.ts', line: 9, text: 'msg("at a.ts:3: boom")' },
97
+ ])
98
+ })
99
+
100
+ it('ls:条目为文件名(目录保留 / 后缀),target 缺省为 .', () => {
101
+ const s = summarizeAgentTool('ls', {}, 'README.md\nsrc/\npackage.json')
102
+ expect(s.target).toBe('.')
103
+ expect(s.entries).toEqual([
104
+ { kind: 'file', path: 'README.md' },
105
+ { kind: 'file', path: 'src/' },
106
+ { kind: 'file', path: 'package.json' },
107
+ ])
108
+ expect(s.total).toBe(3)
109
+ expect(s.truncated).toBe(false)
110
+ })
111
+
112
+ it('find:target 为 glob,条目为相对路径', () => {
113
+ const s = summarizeAgentTool('find', { pattern: '**/*.ts', path: 'src' }, 'src/a.ts\nsrc/b/c.ts')
114
+ expect(s.target).toBe('**/*.ts')
115
+ expect(s.entries.map((e) => (e.kind === 'file' ? e.path : null))).toEqual(['src/a.ts', 'src/b/c.ts'])
116
+ })
117
+
118
+ it('read:target 为文件路径,条目为内容行,尾部 Showing lines 提示剥离并置 truncated', () => {
119
+ const s = summarizeAgentTool(
120
+ 'read',
121
+ { path: 'README.md' },
122
+ '# Title\n\nbody line\n\n[Showing lines 1-3 of 900. Use offset=4 to continue.]',
123
+ )
124
+ expect(s.target).toBe('README.md')
125
+ expect(s.entries).toEqual([
126
+ { kind: 'text', text: '# Title' },
127
+ { kind: 'text', text: '' },
128
+ { kind: 'text', text: 'body line' },
129
+ ])
130
+ expect(s.truncated).toBe(true)
131
+ })
132
+
133
+ it('read:正文本身以方括号结尾(非 pi 提示措辞)不被误剥离', () => {
134
+ const s = summarizeAgentTool('read', { path: 'notes.md' }, 'intro\n\n[TODO]')
135
+ expect(s.entries.map((e) => (e.kind === 'text' ? e.text : null))).toEqual(['intro', '', '[TODO]'])
136
+ expect(s.truncated).toBe(false)
137
+ })
138
+
139
+ it('空结果哨兵:grep 无匹配 / ls 空目录 / find 无文件 → empty 且无条目', () => {
140
+ expect(summarizeAgentTool('grep', { pattern: 'zzz' }, 'No matches found')).toMatchObject({ empty: true, entries: [], total: 0 })
141
+ expect(summarizeAgentTool('ls', { path: 'empty' }, '(empty directory)')).toMatchObject({ empty: true, entries: [] })
142
+ expect(summarizeAgentTool('find', { pattern: '*.x' }, 'No files found matching pattern')).toMatchObject({ empty: true })
143
+ })
144
+
145
+ it('条目超过展示上限:entries 截至上限、total 保留真实数量', () => {
146
+ const lines = Array.from({ length: AGENT_TOOL_MAX_ENTRIES + 7 }, (_, i) => `f${i}.txt`).join('\n')
147
+ const s = summarizeAgentTool('ls', {}, lines)
148
+ expect(s.entries).toHaveLength(AGENT_TOOL_MAX_ENTRIES)
149
+ expect(s.total).toBe(AGENT_TOOL_MAX_ENTRIES + 7)
150
+ })
151
+
152
+ it('未知工具:kind=other、target=null、条目为文本行;无结果文本 → 无条目', () => {
153
+ expect(summarizeAgentTool('custom', {}, 'line1\nline2')).toMatchObject({
154
+ kind: 'other',
155
+ target: null,
156
+ entries: [
157
+ { kind: 'text', text: 'line1' },
158
+ { kind: 'text', text: 'line2' },
159
+ ],
160
+ })
161
+ expect(summarizeAgentTool('read', { path: 'a' }, null)).toMatchObject({ entries: [], total: 0, empty: false })
162
+ })
163
+ })