From 158556a8fef21e5af6a6d381f2ae807373f4fdcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 11 Aug 2026 19:40:22 +0800 Subject: [PATCH 1/5] =?UTF-8?q?=E2=9C=A8=20=E4=B8=AD=E7=BB=A7=E6=94=BE?= =?UTF-8?q?=E5=BC=80=20kind=3Ddesktop=EF=BC=9A=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E7=99=BB=E8=AE=B0=E5=8F=AF=E5=AF=BB=E5=9D=80=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E5=B9=B6=E8=A2=AB=E5=90=8C=E8=B4=A6=E5=8F=B7=E8=A7=A3=E6=9E=90?= =?UTF-8?q?=EF=BC=8C=E8=B7=A8=E8=B4=A6=E5=8F=B7/=E9=9D=9E=E6=B4=BB?= =?UTF-8?q?=E8=B7=83/=E5=B7=B2=E8=A7=A3=E9=99=A4=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E4=BB=8D=E6=8B=92=EF=BC=88=E4=BB=BB=E5=8A=A1=201=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/controller/relay_ctr/relay_test.go | 55 +++++++++++++-- internal/service/relay_svc/relay.go | 10 ++- internal/service/relay_svc/relay_test.go | 75 ++++++++++++++------- 3 files changed, 107 insertions(+), 33 deletions(-) diff --git a/internal/controller/relay_ctr/relay_test.go b/internal/controller/relay_ctr/relay_test.go index e2af0dff..62514749 100644 --- a/internal/controller/relay_ctr/relay_test.go +++ b/internal/controller/relay_ctr/relay_test.go @@ -227,6 +227,53 @@ func TestRelayEndpointsRequireDeviceJWTAndDaemonRenewsOnFrames(t *testing.T) { } } +func TestDesktopRelayTargetCanBeAddressedThroughEndpoints(t *testing.T) { + gin.SetMode(gin.TestMode) + testutils.Redis() + mini := miniredis.RunT(t) + signer, err := jwt.NewSigner(testkeys.PrivatePEM, testkeys.PublicPEM, "agentre-server", "agentre") + require.NoError(t, err) + + controller := gomock.NewController(t) + devices := mock_device_repo.NewMockDeviceRepo(controller) + desktop := &device_entity.Device{ + ID: 10, UserID: 7, Kind: device_entity.KindDesktop, Fingerprint: "fp-desktop", Status: 1, + } + devices.EXPECT().Find(gomock.Any(), desktop.ID).Return(desktop, nil) + devices.EXPECT().FindByFingerprint(gomock.Any(), desktop.UserID, desktop.Fingerprint).Return(desktop, nil) + + config := relay_svc.Config{InstanceID: "server-a", OnlineTTL: time.Second} + redisClient := newRelayRedisClient(t, mini) + server := newRelayServer(t, signer, relay_svc.New( + config, devices, redisClient, relay_svc.NewRedisForwarder(config, redisClient), + )) + targetToken, _, err := signer.Sign(jwt.Claims{ + UID: desktop.UserID, DID: desktop.ID, Kind: device_entity.KindDesktop, + }, time.Hour) + require.NoError(t, err) + clientToken, _, err := signer.Sign(jwt.Claims{ + UID: desktop.UserID, DID: 4, Kind: device_entity.KindAgentred, + }, time.Hour) + require.NoError(t, err) + + targetConn, _, err := websocket.DefaultDialer.Dial( + wsURL(server.URL, "/v1/relay/daemon"), + http.Header{"Authorization": {"Bearer " + targetToken}}, + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, targetConn.Close()) }) + + clientConn, response, err := websocket.DefaultDialer.Dial( + wsURL(server.URL, "/v1/relay/client?daemon_fingerprint="+desktop.Fingerprint), + http.Header{"Authorization": {"Bearer " + clientToken}}, + ) + if response != nil { + t.Cleanup(func() { require.NoError(t, response.Body.Close()) }) + } + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, clientConn.Close()) }) +} + func TestRelayLifecycleRejectsOversizedMessagesAndDetaches(t *testing.T) { gin.SetMode(gin.TestMode) testutils.Redis() @@ -321,15 +368,15 @@ func TestRelayClientForwardingErrorStillClosesClientConnection(t *testing.T) { require.Error(t, err) } -// PrepareDaemon 的准入判据(本账号名下、活跃的 agentred)被拒时必须走 403,而且 -// 必须**在 websocket upgrade 之前**答复:一个 desktop 端的 device JWT 拿不到升级后 -// 的连接,也就没机会占住这个账号+指纹的中继路由。403 这一支此前没有任何测试。 +// PrepareDaemon 的准入判据(本账号名下、活跃且可寻址的设备)被拒时必须走 403, +// 而且必须**在 websocket upgrade 之前**答复:不支持的设备种类拿不到升级后的连接, +// 也就没机会占住这个账号+指纹的中继路由。 func TestRelayDaemonForbiddenAnswers403BeforeUpgrading(t *testing.T) { gin.SetMode(gin.TestMode) testutils.Redis() signer, err := jwt.NewSigner(testkeys.PrivatePEM, testkeys.PublicPEM, "agentre-server", "agentre") require.NoError(t, err) - token, _, err := signer.Sign(jwt.Claims{UID: 7, DID: 4, Kind: device_entity.KindDesktop}, time.Hour) + token, _, err := signer.Sign(jwt.Claims{UID: 7, DID: 4, Kind: device_entity.KindWeb}, time.Hour) require.NoError(t, err) stub := &relayStub{ diff --git a/internal/service/relay_svc/relay.go b/internal/service/relay_svc/relay.go index 021646ad..c225667a 100644 --- a/internal/service/relay_svc/relay.go +++ b/internal/service/relay_svc/relay.go @@ -155,19 +155,23 @@ func New(config Config, devices device_repo.DeviceRepo, redisClient *goredis.Cli } func (s *relaySvc) PrepareDaemon(ctx context.Context, accountID, deviceID int64, kind string) (Route, error) { - if kind != device_entity.KindAgentred { + if !isAddressableKind(kind) { return Route{}, ErrDaemonForbidden } device, err := s.devices.Find(ctx, deviceID) if err != nil { return Route{}, err } - if device == nil || device.UserID != accountID || device.Kind != device_entity.KindAgentred || !device.IsActive() { + if device == nil || device.UserID != accountID || !isAddressableKind(device.Kind) || !device.IsActive() { return Route{}, ErrDaemonForbidden } return Route{AccountID: accountID, Fingerprint: device.Fingerprint, InstanceID: s.config.InstanceID}, nil } +func isAddressableKind(kind string) bool { + return kind == device_entity.KindAgentred || kind == device_entity.KindDesktop +} + func (s *relaySvc) RegisterDaemon(ctx context.Context, route Route) error { if err := s.redis.Set(ctx, routeKey(route.AccountID, route.Fingerprint), route.InstanceID, s.config.OnlineTTL).Err(); err != nil { return fmt.Errorf("register relay daemon: %w", err) @@ -198,7 +202,7 @@ func (s *relaySvc) ConnectClient(ctx context.Context, accountID int64, fingerpri if err != nil { return Route{}, err } - if device == nil || device.Kind != device_entity.KindAgentred || !device.IsActive() { + if device == nil || !isAddressableKind(device.Kind) || !device.IsActive() { return Route{}, ErrDaemonNotFound } diff --git a/internal/service/relay_svc/relay_test.go b/internal/service/relay_svc/relay_test.go index 59a42c91..c400cf4e 100644 --- a/internal/service/relay_svc/relay_test.go +++ b/internal/service/relay_svc/relay_test.go @@ -39,6 +39,13 @@ func activeDaemon() *device_entity.Device { } } +func activeDesktop() *device_entity.Device { + return &device_entity.Device{ + ID: 10, UserID: 7, Kind: device_entity.KindDesktop, + Fingerprint: "fp-desktop", Status: 1, + } +} + func TestIsDaemonOnline(t *testing.T) { ctx := context.Background() svc, mini, _ := newRelayForTest(t, fakeForwarder{}) @@ -155,26 +162,34 @@ func TestAttachClientDetachSignalsChannelCloseToDaemon(t *testing.T) { } } -// PrepareDaemon 是 /v1/relay/daemon 唯一的准入判据:只有本账号名下、活跃的 -// agentred 设备才能把自己登记成中转目标。没有这几条,一个 desktop 端的 device JWT -// 就能冒充计算节点占住这个账号+指纹的中继路由,或者拿别人账号下的 deviceID 登记。 -// 这些守卫此前一条测试都没有,整段删掉全绿。 -func TestPrepareDaemonRejectsAnythingButThisAccountsActiveAgentred(t *testing.T) { +func TestDesktopCanRegisterAndBeResolvedWithinAccount(t *testing.T) { ctx := context.Background() + svc, _, devices := newRelayForTest(t, fakeForwarder{}) + desktop := activeDesktop() + devices.EXPECT().Find(gomock.Any(), desktop.ID).Return(desktop, nil) + devices.EXPECT().FindByFingerprint(gomock.Any(), desktop.UserID, desktop.Fingerprint).Return(desktop, nil) - t.Run("非 agentred 设备种类", func(t *testing.T) { - svc, _, _ := newRelayForTest(t, fakeForwarder{}) - // kind 在读库之前就被拒,连 Find 都不该发生(devices mock 没有 EXPECT)。 - _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindDesktop) - require.ErrorIs(t, err, ErrDaemonForbidden) - }) + route, err := svc.PrepareDaemon(ctx, desktop.UserID, desktop.ID, device_entity.KindDesktop) + require.NoError(t, err) + require.Equal(t, Route{ + AccountID: desktop.UserID, Fingerprint: desktop.Fingerprint, InstanceID: "server-a", + }, route) + require.NoError(t, svc.RegisterDaemon(ctx, route)) + + resolved, err := svc.ConnectClient(ctx, desktop.UserID, desktop.Fingerprint) + require.NoError(t, err) + require.Equal(t, route, resolved) +} + +// PrepareDaemon 是 /v1/relay/daemon 唯一的准入判据:只有本账号名下、活跃且可寻址的 +// agentred 或 desktop 设备才能把自己登记成中转目标。kind、账号归属和撤销状态必须在 +// websocket upgrade 前复核,避免无权设备占住账号+指纹的中继路由。 +func TestPrepareDaemonAcceptsOnlyThisAccountsActiveAddressableDevices(t *testing.T) { + ctx := context.Background() - t.Run("kind=web 无法登记为可寻址目标(R3 守卫断言)", func(t *testing.T) { + t.Run("kind=web 无法登记为可寻址目标", func(t *testing.T) { svc, _, _ := newRelayForTest(t, fakeForwarder{}) - // 浏览器与桌面端一样是中继上的纯出站调用方:持有有效设备 JWT 只能连 - // /v1/relay/client,绝不能把自己登记成可被寻址的目标(也不能被派活)。 - // 这条由 PrepareDaemon 的既有 kind 判定执行——同样在读库之前就被拒, - // 连 Find 都不该发生(devices mock 没有 EXPECT)。 + // kind 在读库之前就被拒,连 Find 都不该发生(devices mock 没有 EXPECT)。 _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindWeb) require.ErrorIs(t, err, ErrDaemonForbidden) }) @@ -188,17 +203,14 @@ func TestPrepareDaemonRejectsAnythingButThisAccountsActiveAgentred(t *testing.T) require.ErrorIs(t, err, ErrDaemonForbidden) }) - t.Run("设备已被撤销", func(t *testing.T) { + t.Run("撤销一台 desktop 不影响其它设备", func(t *testing.T) { svc, _, devices := newRelayForTest(t, fakeForwarder{}) - revoked := activeDaemon() + revoked := activeDesktop() revoked.Status = 2 - devices.EXPECT().Find(gomock.Any(), int64(9)).Return(revoked, nil) - _, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindAgentred) + devices.EXPECT().Find(gomock.Any(), revoked.ID).Return(revoked, nil) + _, err := svc.PrepareDaemon(ctx, revoked.UserID, revoked.ID, device_entity.KindDesktop) require.ErrorIs(t, err, ErrDaemonForbidden) - }) - t.Run("本账号活跃的 agentred 才放行", func(t *testing.T) { - svc, _, devices := newRelayForTest(t, fakeForwarder{}) devices.EXPECT().Find(gomock.Any(), int64(9)).Return(activeDaemon(), nil) route, err := svc.PrepareDaemon(ctx, 7, 9, device_entity.KindAgentred) require.NoError(t, err) @@ -834,11 +846,22 @@ func TestDefaultIsNeverNilWithoutRegistration(t *testing.T) { func TestRelayClientFailuresAreDistinguishable(t *testing.T) { ctx := context.Background() - t.Run("daemon is not registered to this account", func(t *testing.T) { + t.Run("target belongs to another account", func(t *testing.T) { svc, _, devices := newRelayForTest(t, fakeForwarder{}) - devices.EXPECT().FindByFingerprint(gomock.Any(), int64(7), "fp-unknown").Return(nil, nil) + // FindByFingerprint 按账号限定查询;另一个账号里的同指纹对账号 7 不可见。 + devices.EXPECT().FindByFingerprint(gomock.Any(), int64(7), "fp-other-account").Return(nil, nil) + + _, err := svc.ConnectClient(ctx, 7, "fp-other-account") + require.ErrorIs(t, err, ErrDaemonNotFound) + }) + + t.Run("revoked desktop is not addressable", func(t *testing.T) { + svc, _, devices := newRelayForTest(t, fakeForwarder{}) + revoked := activeDesktop() + revoked.Status = 2 + devices.EXPECT().FindByFingerprint(gomock.Any(), revoked.UserID, revoked.Fingerprint).Return(revoked, nil) - _, err := svc.ConnectClient(ctx, 7, "fp-unknown") + _, err := svc.ConnectClient(ctx, revoked.UserID, revoked.Fingerprint) require.ErrorIs(t, err, ErrDaemonNotFound) }) From 6229cfe81af592130ae44ca72a36de4df156699a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Tue, 11 Aug 2026 23:40:27 +0800 Subject: [PATCH 2/5] =?UTF-8?q?=E2=9C=A8=20Web=20=E6=8E=A7=E5=88=B6?= =?UTF-8?q?=E5=8F=B0=E6=94=AF=E6=8C=81=E6=A1=8C=E9=9D=A2=E7=AB=AF=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E4=B8=8B=E9=92=BB=E3=80=81=E4=BC=9A=E8=AF=9D=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E4=B8=8E=E5=85=AD=E7=B1=BB=E7=8A=B6=E6=80=81=E8=A1=A8?= =?UTF-8?q?=E8=BE=BE=EF=BC=88=E4=BB=BB=E5=8A=A1=208=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/__tests__/chat.test.tsx | 117 +++++++++++++++ frontend/src/__tests__/device-expand.test.tsx | 73 +++++++++ .../src/__tests__/device-sessions.test.tsx | 79 +++++++++- .../src/__tests__/session-detail.test.tsx | 33 +++++ frontend/src/__tests__/session-merge.test.ts | 95 ++++++++++++ .../src/__tests__/session-status.test.tsx | 53 +++++-- frontend/src/components/session/ChatList.tsx | 21 ++- .../components/session/SessionDetailView.tsx | 33 ++++- .../session/SessionStatusBanner.tsx | 13 +- frontend/src/i18n/locales/en.json | 8 + frontend/src/i18n/locales/zh-CN.json | 8 + frontend/src/lib/sessionMerge.ts | 50 +++++++ frontend/src/lib/sessionView.ts | 44 ++++-- frontend/src/pages/Chat.tsx | 140 +++++++++++++----- frontend/src/pages/DeviceSessions.tsx | 40 +++-- frontend/src/pages/Devices.tsx | 41 +++-- 16 files changed, 745 insertions(+), 103 deletions(-) create mode 100644 frontend/src/__tests__/session-merge.test.ts create mode 100644 frontend/src/lib/sessionMerge.ts diff --git a/frontend/src/__tests__/chat.test.tsx b/frontend/src/__tests__/chat.test.tsx index 13c1bf75..44bf935d 100644 --- a/frontend/src/__tests__/chat.test.tsx +++ b/frontend/src/__tests__/chat.test.tsx @@ -497,6 +497,123 @@ describe("对话页:行上的时间是最后活动时间", () => { }); }); +describe("对话页:R20 重复会话合并", () => { + const desktop = { + id: 3, + name: "工作 MacBook", + kind: "desktop", + fingerprint: "fp-desktop", + last_seen_at: 1754000000000, + status: 1, + online: true, + }; + + function duplicateFollows() { + return [ + { + device_fingerprint: "fp-agentred", + session_id: "42", + followed_at: 1754000000000, + invalid: false, + }, + { + device_fingerprint: "fp-desktop", + session_id: "42", + followed_at: 1754000000000, + invalid: false, + }, + ]; + } + + it("同键的桌面端与 agentred 摘要只呈现桌面端完整副本", async () => { + const compute = { + ...agentred, + fingerprint: "fp-agentred", + }; + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/follows") return { items: duplicateFollows() }; + if (path === "/v1/devices") return { devices: [compute, desktop] }; + if (path === "/v1/workspace/agents") return { agents }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockImplementation((fingerprint) => ({ + ...connectedRelay(), + client: { + ...fakeClient, + request: vi.fn(async () => ({ + sessions: [ + { + ...summary, + peerFingerprint: "fp-desktop", + title: + fingerprint === "fp-desktop" + ? "Complete desktop title" + : "Partial agentred title", + }, + ], + supportsSessionMetadata: true, + })), + } as never, + })); + + renderChat(); + + // 会话在「最近」区与所属 Agent 分组各渲染一次(T6),用 *AllBy 断言合并结果: + // 只剩桌面端完整副本,没有 agentred 退化副本,也不带历史不完整说明。 + expect( + (await screen.findAllByText("Complete desktop title")).length, + ).toBeGreaterThan(0); + expect(screen.queryByText("Partial agentred title")).toBeNull(); + expect(screen.queryByText(/History is incomplete/)).toBeNull(); + }); + + it("桌面端副本不在场时退到 agentred,并显示历史不完整说明", async () => { + const compute = { + ...agentred, + fingerprint: "fp-agentred", + }; + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/follows") { + return { items: [duplicateFollows()[0]] }; + } + if (path === "/v1/devices") { + return { devices: [compute, { ...desktop, online: false }] }; + } + if (path === "/v1/workspace/agents") return { agents }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockReturnValue({ + ...connectedRelay(), + client: { + ...fakeClient, + request: vi.fn(async () => ({ + sessions: [ + { + ...summary, + peerFingerprint: "fp-desktop", + title: "Partial agentred title", + }, + ], + supportsSessionMetadata: true, + })), + } as never, + }); + + renderChat(); + + // 会话在「最近」区与所属 Agent 分组各渲染一次(T6),用 *AllBy 断言合并结果: + // 桌面端副本不在场时退到 agentred 副本并带历史不完整说明。 + expect( + (await screen.findAllByText("Partial agentred title")).length, + ).toBeGreaterThan(0); + expect( + screen.getAllByText( + "History is incomplete — showing only the part retained by agentred.", + ).length, + ).toBeGreaterThan(0); + }); +}); + // R13:这一页的每一行都靠 runtime.session.list 解析(标题 / 状态 / 等待输入 / // 最后活动时间)。机器掉线再回来时必须**重新**解析一次:断连期间那条对话可能跑完 // 了、可能停下来等审批,而页面上还挂着断线前那一刻的状态——用户对着一个早就过时 diff --git a/frontend/src/__tests__/device-expand.test.tsx b/frontend/src/__tests__/device-expand.test.tsx index f8194c53..267bc617 100644 --- a/frontend/src/__tests__/device-expand.test.tsx +++ b/frontend/src/__tests__/device-expand.test.tsx @@ -151,6 +151,79 @@ describe("device row expand", () => { expect(within(card).getByText(/View this machine/i)).toBeTruthy(); }); + it("expanding an active desktop row shows conversation counts and an enter action", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") { + return { + devices: [ + { + ...listResponse.devices[1], + online: true, + is_this_device: false, + }, + ], + }; + } + if (path === "/v1/workspace/device-detail?device_id=2") { + return { + device_id: 2, + kind: "desktop", + projects: [], + }; + } + throw new Error("unexpected call: " + path); + }); + + renderDevices(); + const card = (await screen.findByText("laptop")).closest( + '[data-slot="card"]', + ) as HTMLElement; + fireEvent.click( + within(card).getByRole("button", { name: /show details/i }), + ); + + expect(await within(card).findByText("Conversations")).toBeTruthy(); + expect(await within(card).findByText("3 conversations")).toBeTruthy(); + expect(within(card).getByText("1 waiting for you")).toBeTruthy(); + const link = within(card).getByRole("link", { + name: "View this desktop's conversations", + }); + expect(link.getAttribute("href")).toBe("/devices/2/sessions"); + }); + + it("an inactive desktop says Agentre is not running and cannot be entered", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") return listResponse; + if (path === "/v1/workspace/device-detail?device_id=2") { + return { + device_id: 2, + kind: "desktop", + projects: [], + }; + } + throw new Error("unexpected call: " + path); + }); + + renderDevices(); + const card = (await screen.findByText("laptop")).closest( + '[data-slot="card"]', + ) as HTMLElement; + expect(within(card).getByText(/Agentre is not running/)).toBeTruthy(); + expect(within(card).queryByText(/^Offline$/)).toBeNull(); + + fireEvent.click( + within(card).getByRole("button", { name: /show details/i }), + ); + expect( + await within(card).findByText( + /Agentre is not running on this computer\. Open Agentre to view its conversations\./, + ), + ).toBeTruthy(); + expect(within(card).queryByRole("link", { name: /conversations/i })).toBe( + null, + ); + }); + // 帧 47:浏览器行不接单,也**不可展开** —— 展开它只会去问一台没有项目、没有 // Agent 的「设备」,把 agentred 的那套详情套在浏览器上是错的。 it("a kind=web row has no expand control", async () => { diff --git a/frontend/src/__tests__/device-sessions.test.tsx b/frontend/src/__tests__/device-sessions.test.tsx index faaa380f..c31e4cc4 100644 --- a/frontend/src/__tests__/device-sessions.test.tsx +++ b/frontend/src/__tests__/device-sessions.test.tsx @@ -116,8 +116,85 @@ describe("设备会话列表页", () => { expect(fakeClient.request).toHaveBeenCalledWith("runtime.session.list"); }); + it("在线桌面端复用同一套会话列表并消费完整 SessionSummary", async () => { + const desktop = { + ...deviceRow, + id: 1, + name: "工作 MacBook", + kind: "desktop", + fingerprint: "fp-desktop", + }; + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") return { devices: [desktop] }; + if (path === "/v1/workspace/agents") { + return { agents: [{ sync_id: "ag-1", name: "后端 Agent" }] }; + } + if (path === "/v1/follows") return { items: [] }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockReturnValue({ + client: { + ...fakeClient, + request: vi.fn(async () => ({ + sessions: [ + { + ...sessions[0], + peerFingerprint: "fp-desktop", + title: "桌面完整标题", + }, + ], + supportsSessionMetadata: true, + })), + } as never, + relayState: "connected", + webDevice: { fingerprint: "fp-web", accessToken: "t", deviceId: 9 }, + webDeviceError: null, + }); + + renderPage(); + + expect(await screen.findByText("桌面完整标题")).toBeTruthy(); + // 桌面端列表等待输入时用状态点 + aria-label 表达(#10 起文字徽标只在移动端)。 + expect( + screen.getByTestId("session-dot-42").getAttribute("aria-label"), + ).toBe("Waiting for your input"); + expect(mockUseRelay).toHaveBeenCalledWith("fp-desktop"); + expect(screen.queryByText("Unnamed")).toBeNull(); + }); + + it("未运行的桌面端直达列表页时说明打开 Agentre,不借用机器离线措辞", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") { + return { + devices: [ + { + ...deviceRow, + kind: "desktop", + fingerprint: "fp-desktop", + online: false, + }, + ], + }; + } + if (path === "/v1/workspace/agents") return { agents: [] }; + throw new Error("unexpected: " + path); + }); + mockUseRelay.mockReturnValue({ + client: null, + relayState: "disconnected", + webDevice: null, + webDeviceError: null, + }); + + renderPage(); + + expect(await screen.findByText(/Open Agentre to continue/)).toBeTruthy(); + expect(screen.queryByText(/This machine is offline/)).toBeNull(); + expect(mockUseRelay).not.toHaveBeenCalledWith("fp-desktop"); + }); + // 界面(决策 11 / 帧 45a):「行尾一个「换机器」入口**就地切换**」——不是把人送回 - // 设备页再从头下钻一次。就地列出账号下的其余 agentred,选中即换到那台机器的会话列表。 + // 设备页再从头下钻一次。就地列出账号下的其余目标,选中即换到那台机器的会话列表。 it("面包屑行尾「换机器」就地列出其余 agentred 并切过去", async () => { const other = { id: 2, diff --git a/frontend/src/__tests__/session-detail.test.tsx b/frontend/src/__tests__/session-detail.test.tsx index 3cf0ddf0..8d5e40f1 100644 --- a/frontend/src/__tests__/session-detail.test.tsx +++ b/frontend/src/__tests__/session-detail.test.tsx @@ -489,6 +489,39 @@ describe("会话详情页:老 agentred 与发送失败", () => { ).toBe(true); }); + it("桌面端在场但钉住的 agentred 不可用:历史仍可读、新写入停用并给专门说明", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/devices") { + return { devices: [{ ...deviceRow, kind: "desktop" }] }; + } + throw new Error("unexpected: " + path); + }); + fakeClient.request.mockImplementation(async (method) => { + if (method === "runtime.session.list") { + return { sessions: [summary], supportsSessionMetadata: true }; + } + if (method === "runtime.session.pendingWaiters") { + return { toolPermissions: [], askUserQuestions: [] }; + } + if (method === "runtime.run") throw new Error("pinned agentred offline"); + throw new Error("unexpected: " + method); + }); + + renderPage(); + await screen.findByText(/重构登录页/); + + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "继续" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send" })); + + expect( + await screen.findByText(/Conversation history is still available/), + ).toBeTruthy(); + expect(screen.getByRole("textbox").hasAttribute("disabled")).toBe(true); + expect(screen.queryByText(/draft is kept/)).toBeNull(); + }); + it("发送失败:就地报错,不静默吞掉", async () => { mockedApi.mockImplementation(async (path) => { if (path === "/v1/devices") return { devices: [deviceRow] }; diff --git a/frontend/src/__tests__/session-merge.test.ts b/frontend/src/__tests__/session-merge.test.ts new file mode 100644 index 00000000..26f27450 --- /dev/null +++ b/frontend/src/__tests__/session-merge.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; + +import { mergeSessionCopies } from "@/lib/sessionMerge"; + +const desktopSummary = { + sessionId: 42, + peerFingerprint: "fp-desktop", + title: "Complete desktop title", + agentSyncId: "ag-1", + lifecycleState: "idle", + latestSeq: 9, +}; + +const agentredSummary = { + ...desktopSummary, + title: "Partial agentred title", + latestSeq: 4, +}; + +describe("duplicate session merge", () => { + it("merges by peer fingerprint plus session id and prefers the desktop copy", () => { + const merged = mergeSessionCopies( + [ + { + sourceFingerprint: "fp-agentred", + sourceKind: "agentred", + summary: agentredSummary, + }, + { + sourceFingerprint: "fp-desktop", + sourceKind: "desktop", + summary: desktopSummary, + }, + ], + new Set(["fp-desktop"]), + ); + + expect(merged).toHaveLength(1); + expect(merged[0].sourceKind).toBe("desktop"); + expect(merged[0].summary.title).toBe("Complete desktop title"); + expect(merged[0].historyIncomplete).toBe(false); + + const reversed = mergeSessionCopies( + [ + { + sourceFingerprint: "fp-desktop", + sourceKind: "desktop", + summary: desktopSummary, + }, + { + sourceFingerprint: "fp-agentred", + sourceKind: "agentred", + summary: agentredSummary, + }, + ], + new Set(["fp-desktop"]), + ); + expect(reversed).toHaveLength(1); + expect(reversed[0].sourceKind).toBe("desktop"); + }); + + it("does not merge equal session ids from different peer fingerprints", () => { + const merged = mergeSessionCopies([ + { + sourceFingerprint: "fp-agentred-a", + sourceKind: "agentred", + summary: agentredSummary, + }, + { + sourceFingerprint: "fp-agentred-b", + sourceKind: "agentred", + summary: { ...agentredSummary, peerFingerprint: "fp-other" }, + }, + ]); + + expect(merged).toHaveLength(2); + }); + + it("falls back to the agentred copy and marks its retained history incomplete", () => { + const merged = mergeSessionCopies( + [ + { + sourceFingerprint: "fp-agentred", + sourceKind: "agentred", + summary: agentredSummary, + }, + ], + new Set(["fp-desktop"]), + ); + + expect(merged).toHaveLength(1); + expect(merged[0].sourceKind).toBe("agentred"); + expect(merged[0].historyIncomplete).toBe(true); + }); +}); diff --git a/frontend/src/__tests__/session-status.test.tsx b/frontend/src/__tests__/session-status.test.tsx index a40b2f60..8a7ae168 100644 --- a/frontend/src/__tests__/session-status.test.tsx +++ b/frontend/src/__tests__/session-status.test.tsx @@ -1,11 +1,11 @@ /** - * R11 测试接缝:四类不可达与失效各自可区分、可访问性的文字标注。 + * R11 测试接缝:六类失败/失效各自可区分、可访问性的文字标注。 * * 断言三件事: - * 1. 七个视图状态渲染出**互不相同**的文案(不折叠成同一个错误)。 - * 2. 状态不只靠颜色:运行 / 等待输入 / 离线 / 解除授权都有文字(getByText 能取到)。 - * 3. 实时通知类(reconnecting / connecting)走 role="status",失败类 - * (lost / machineOffline / revoked / loggedOut)走 role="alert"。 + * 1. 所有视图状态渲染出**互不相同**的文案(不折叠成同一个错误)。 + * 2. 状态不只靠颜色:六类失败都有可见文字(getByText 能取到)。 + * 3. 实时通知类(reconnecting / connecting)走 role="status",失败类走 + * role="alert"。 */ import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it } from "vitest"; @@ -37,11 +37,13 @@ const ALL_STATUSES: SessionViewStatus[] = [ "reconnecting", "lost", "machineOffline", + "desktopAppNotRunning", + "pinnedAgentredUnavailable", "revoked", "loggedOut", ]; -describe("会话状态:四类不可达与失效各自可区分", () => { +describe("会话状态:六类失败与失效各自可区分", () => { it("每种状态渲染出互不相同的可见文案", () => { const texts = new Map(); for (const status of ALL_STATUSES) { @@ -61,16 +63,25 @@ describe("会话状态:四类不可达与失效各自可区分", () => { seen.add(own); texts.set(status, own); } - // machineOffline / revoked / loggedOut 三者文案互不相同(四类不折叠)。 - expect(texts.get("machineOffline")).not.toBe(texts.get("revoked")); - expect(texts.get("machineOffline")).not.toBe(texts.get("loggedOut")); - expect(texts.get("revoked")).not.toBe(texts.get("loggedOut")); + const failures: SessionViewStatus[] = [ + "machineOffline", + "lost", + "revoked", + "loggedOut", + "desktopAppNotRunning", + "pinnedAgentredUnavailable", + ]; + expect(new Set(failures.map((status) => texts.get(status))).size).toBe(6); }); it("状态不只靠颜色:关键状态都有可见文字(而非仅图标/色块)", () => { renderStatus("machineOffline"); + renderStatus("desktopAppNotRunning"); + renderStatus("pinnedAgentredUnavailable"); renderStatus("revoked"); - expect(screen.getAllByRole("alert").length).toBe(2); + renderStatus("loggedOut"); + renderStatus("lost"); + expect(screen.getAllByRole("alert").length).toBe(6); // 文字存在且非空。 const alerts = screen.getAllByRole("alert"); for (const a of alerts) { @@ -86,17 +97,21 @@ describe("会话状态:四类不可达与失效各自可区分", () => { expect(screen.getAllByRole("alert").length).toBe(2); }); - it("deriveSessionViewStatus 把四类信号各自映射到不同状态", () => { + it("deriveSessionViewStatus 把六类失败信号各自映射到不同状态", () => { const base: { relayState: RelayState; meValid: boolean; webDeviceRevoked: boolean; machineOnline: boolean | null; + targetKind: "agentred" | "desktop"; + pinnedAgentredUnavailable: boolean; } = { relayState: "disconnected", meValid: true, webDeviceRevoked: false, machineOnline: true, + targetKind: "agentred", + pinnedAgentredUnavailable: false, }; expect(deriveSessionViewStatus({ ...base, meValid: false })).toBe( "loggedOut", @@ -107,6 +122,20 @@ describe("会话状态:四类不可达与失效各自可区分", () => { expect(deriveSessionViewStatus({ ...base, machineOnline: false })).toBe( "machineOffline", ); + expect( + deriveSessionViewStatus({ + ...base, + targetKind: "desktop", + machineOnline: false, + }), + ).toBe("desktopAppNotRunning"); + expect( + deriveSessionViewStatus({ + ...base, + relayState: "connected", + pinnedAgentredUnavailable: true, + }), + ).toBe("pinnedAgentredUnavailable"); expect(deriveSessionViewStatus({ ...base, relayState: "connected" })).toBe( "connected", ); diff --git a/frontend/src/components/session/ChatList.tsx b/frontend/src/components/session/ChatList.tsx index e15ed93c..2f8d81e2 100644 --- a/frontend/src/components/session/ChatList.tsx +++ b/frontend/src/components/session/ChatList.tsx @@ -58,6 +58,8 @@ export interface ChatSessionRow { deviceName: string; followedAt: number; summary: SessionSummary; + /** 桌面副本不在场而退到 agentred 副本时,只读得到执行端保留的部分历史。 */ + historyIncomplete?: boolean; /** 移动形态下钉在行上的 Agent 名称与头像色(R13,见 regroupByStatus)。 */ agentName?: string; agentColor?: string; @@ -76,6 +78,7 @@ export interface ChatOfflineRow { sessionId: number; deviceId: number; deviceName: string; + deviceKind: string; lastSeenAt: number; } @@ -234,6 +237,11 @@ function SessionRowBody({ {row2}

)} + {row.historyIncomplete && ( +

+ {t("session.list.historyIncomplete")} +

+ )} {/* 移动端保文字徽标(不只靠颜色);桌面由状态点 + aria 承担。 */} {isMobile && ( @@ -409,10 +417,15 @@ function OfflineRow({ } >

- {t("chat.offlineMachineWithTime", { - machine: row.deviceName, - time: formatTime(row.lastSeenAt), - })} + {t( + row.deviceKind === "desktop" + ? "chat.desktopAppNotRunningWithTime" + : "chat.offlineMachineWithTime", + { + machine: row.deviceName, + time: formatTime(row.lastSeenAt), + }, + )}

{/* 决策 16:移动的关注入口在详情页顶栏,不在列表行。 */} diff --git a/frontend/src/components/session/SessionDetailView.tsx b/frontend/src/components/session/SessionDetailView.tsx index ba78f1d4..defd12cd 100644 --- a/frontend/src/components/session/SessionDetailView.tsx +++ b/frontend/src/components/session/SessionDetailView.tsx @@ -108,6 +108,9 @@ export default function SessionDetailView({ const [decisionError, setDecisionError] = useState(false); const [sending, setSending] = useState(false); const [sendError, setSendError] = useState(false); + // 桌面端仍在场时写失败:表示该会话钉住的 agentred 当前不可用(历史可读、新写入停用)。 + const [pinnedAgentredUnavailable, setPinnedAgentredUnavailable] = + useState(false); // 未升级的 agentred 的 session.list 应答里没有 supportsSessionMetadata(兼容性)。 const [needsUpgrade, setNeedsUpgrade] = useState(false); const [draft, setDraft] = useState(""); @@ -132,6 +135,7 @@ export default function SessionDetailView({ setHandledRequestId(null); setDecisionError(false); setSendError(false); + setPinnedAgentredUnavailable(false); setNeedsUpgrade(false); setDraft(""); setFollowed(false); @@ -187,7 +191,7 @@ export default function SessionDetailView({ }); const { client, relayState, webDevice, webDeviceError } = useRelayMachine( - device?.fingerprint ?? null, + device?.online ? device.fingerprint : null, { onEvent: (f) => { if (f.sessionId === sid) setEvents((prev) => [...prev, f]); @@ -262,8 +266,10 @@ export default function SessionDetailView({ if (alive) { setSummary(s ?? null); // 老 agentred 落库不了 provider_session_id:从这里发消息续不上上下文。 - // 如实说明它需要升级,并停用发送——不静默发出去(兼容性)。 - setNeedsUpgrade(!list.supportsSessionMetadata); + // 如实说明它需要升级,并停用发送——不静默发出去(兼容性)。桌面端无此问题。 + setNeedsUpgrade( + device?.kind !== "desktop" && !list.supportsSessionMetadata, + ); } await client.attach(sid, origin); await client.catchUp(sid, origin); @@ -281,7 +287,8 @@ export default function SessionDetailView({ // ready 之后不再重复跑(重连时的补齐由 RelayClient.reconnect 对 watched 会话负责); // ready / did / sid 也必须是依赖:切换会话时上面的渲染期重置把 ready 置 false, // 这里要重新 attach 到新会话;只按 [client, relayState] 的话重置后不会重跑。 - }, [client, relayState, ready, did, sid]); + // device?.kind 也要进来:桌面端不存在老 agentred 的升级判定,取数完成后须重算。 + }, [client, relayState, ready, did, sid, device?.kind]); // 首次进入 reconnecting(= 连接失败)时探测原因(R11),只探一次。 // 与文件里其它异步 effect 一样带 alive 守卫:reconnecting 期间切换目标设备或 @@ -374,6 +381,8 @@ export default function SessionDetailView({ meValid, webDeviceRevoked: revokedNow, machineOnline, + targetKind: device?.kind, + pinnedAgentredUnavailable, }); const items = useMemo(() => reduceEvents(events), [events]); @@ -400,9 +409,16 @@ export default function SessionDetailView({ backend: { type: summary.backendType }, }); setDraft(""); + setPinnedAgentredUnavailable(false); } catch { - // 保留草稿,并就地说明这一条没发出去——静默吞掉会让用户以为已经发了。 - setSendError(true); + // 桌面端仍在场时,写失败表示这条会话钉住的 agentred 当前不可用:历史继续 + // 保持可读,但停用新写入并给出专门说明。agentred 目标保留既有发送失败措辞。 + if (device?.kind === "desktop") { + setPinnedAgentredUnavailable(true); + } else { + // 保留草稿,并就地说明这一条没发出去——静默吞掉会让用户以为已经发了。 + setSendError(true); + } } finally { setSending(false); } @@ -484,7 +500,10 @@ export default function SessionDetailView({ } const showTranscript = - ready && (status === "connected" || relayState === "reconnecting"); + ready && + (status === "connected" || + status === "pinnedAgentredUnavailable" || + relayState === "reconnecting"); // 详情头部的机器 meta:「设备 · 在线/离线」拼成同一文本节点(jsx-only 守卫要求 // 文案不出现裸字符串;分隔符在 JS 里拼,不进 JSX)。 diff --git a/frontend/src/components/session/SessionStatusBanner.tsx b/frontend/src/components/session/SessionStatusBanner.tsx index 234a1e2a..f74cebb8 100644 --- a/frontend/src/components/session/SessionStatusBanner.tsx +++ b/frontend/src/components/session/SessionStatusBanner.tsx @@ -4,7 +4,7 @@ import { Alert } from "@/components/ui/alert"; import type { SessionViewStatus } from "@/lib/sessionView"; /** - * R11 状态横幅:四类不可达与失效各有独立文案,不折叠成同一个错误。 + * R11 状态横幅:六类失败与失效各有独立文案,不折叠成同一个错误。 * 可访问性:状态不只靠颜色 —— 每种状态都有可见文字;实时通知类(connecting / * reconnecting)走 role="status",失败类(lost / machineOffline / revoked / * loggedOut)走 role="alert"。connected 是正常态,不渲染横幅。 @@ -41,6 +41,17 @@ export default function SessionStatusBanner({ }) : t("session.status.machineOffline"); break; + case "desktopAppNotRunning": + copy = + machineLastSeenMs && machineLastSeenMs > 0 + ? t("session.status.desktopAppNotRunningWithTime", { + time: new Date(machineLastSeenMs).toLocaleString(), + }) + : t("session.status.desktopAppNotRunning"); + break; + case "pinnedAgentredUnavailable": + copy = t("session.status.pinnedAgentredUnavailable"); + break; case "revoked": copy = t("session.status.revoked"); break; diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index ede935e8..ab2a206c 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -203,7 +203,10 @@ "sessionCount": "{{count}} conversations", "sessionWaiting": "{{count}} waiting for you", "viewSessions": "View this machine's conversations", + "viewDesktopSessions": "View this desktop's conversations", "offlineNotEnterable": "Offline — conversations are unavailable.", + "desktopAppNotRunningShort": "Agentre is not running", + "desktopAppNotRunningDetail": "Agentre is not running on this computer. Open Agentre to view its conversations.", "cardSummary": "{{projects}} projects · {{m}} conversations running" } }, @@ -219,6 +222,9 @@ "lost": "Connection lost. Reconnecting automatically.", "machineOffline": "This machine is offline.", "machineOfflineWithTime": "This machine is offline — last seen {{time}}.", + "desktopAppNotRunning": "Agentre is not running on this computer. Open Agentre to continue.", + "desktopAppNotRunningWithTime": "Agentre is not running on this computer. Open Agentre to continue — last seen {{time}}.", + "pinnedAgentredUnavailable": "Conversation history is still available, but its pinned agentred is unavailable. New messages cannot be sent until agentred is available again.", "revoked": "This browser has been revoked. Please sign in again.", "loggedOut": "You have been signed out. Please sign in again." }, @@ -240,6 +246,7 @@ "idle": "Idle", "interrupted": "Interrupted", "waiting": "Waiting for your input", + "historyIncomplete": "History is incomplete — showing only the part retained by agentred.", "legacy": "{{cwd}} · {{backend}} · {{status}}", "group": { "waiting": "Waiting for you", @@ -298,6 +305,7 @@ "pendingFrom": "Loading conversations from {{machine}}…", "offlineTitle": "Offline", "offlineMachineWithTime": "{{machine}} · offline · last seen {{time}}", + "desktopAppNotRunningWithTime": "{{machine}} · Agentre is not running · last seen {{time}}. Open Agentre to continue.", "invalidTitle": "Unavailable", "invalidBody": "This conversation is no longer reachable.", "noMachine": "Unknown machine", diff --git a/frontend/src/i18n/locales/zh-CN.json b/frontend/src/i18n/locales/zh-CN.json index 44daa7f1..8cd8391d 100644 --- a/frontend/src/i18n/locales/zh-CN.json +++ b/frontend/src/i18n/locales/zh-CN.json @@ -203,7 +203,10 @@ "sessionCount": "{{count}} 条对话", "sessionWaiting": "{{count}} 条等你处理", "viewSessions": "查看这台机器的对话", + "viewDesktopSessions": "查看这个桌面端的对话", "offlineNotEnterable": "已离线——对话不可用。", + "desktopAppNotRunningShort": "Agentre 未运行", + "desktopAppNotRunningDetail": "这台电脑上的 Agentre 没有运行。打开 Agentre 后即可查看它的对话。", "cardSummary": "{{projects}} 个项目 · {{m}} 个对话在跑" } }, @@ -219,6 +222,9 @@ "lost": "连接已丢失,正在自动重连。", "machineOffline": "这台机器已离线。", "machineOfflineWithTime": "这台机器已离线——最后在线 {{time}}。", + "desktopAppNotRunning": "这台电脑上的 Agentre 没有运行。打开 Agentre 后即可继续。", + "desktopAppNotRunningWithTime": "这台电脑上的 Agentre 没有运行。打开 Agentre 后即可继续——最后在线 {{time}}。", + "pinnedAgentredUnavailable": "对话历史仍可阅读,但该会话钉住的 agentred 当前不可用。agentred 恢复前无法发送新消息。", "revoked": "该浏览器已被解除授权,请重新登录。", "loggedOut": "账号已登出,请重新登录。" }, @@ -240,6 +246,7 @@ "idle": "空闲", "interrupted": "已中断", "waiting": "正在等待你的输入", + "historyIncomplete": "历史不完整——当前只显示 agentred 保留的部分。", "legacy": "{{cwd}} · {{backend}} · {{status}}", "group": { "waiting": "等你处理", @@ -298,6 +305,7 @@ "pendingFrom": "正在加载 {{machine}} 上的对话…", "offlineTitle": "离线", "offlineMachineWithTime": "{{machine}} · 离线 · 最后在线 {{time}}", + "desktopAppNotRunningWithTime": "{{machine}} · Agentre 未运行 · 最后在线 {{time}}。打开 Agentre 后即可继续。", "invalidTitle": "已不可用", "invalidBody": "这条对话已经找不到目标了。", "noMachine": "未知机器", diff --git a/frontend/src/lib/sessionMerge.ts b/frontend/src/lib/sessionMerge.ts new file mode 100644 index 00000000..de1749bb --- /dev/null +++ b/frontend/src/lib/sessionMerge.ts @@ -0,0 +1,50 @@ +import type { SessionSummary } from "@/lib/wire"; + +export interface SessionCopy { + sourceFingerprint: string; + sourceKind: string; + summary: SessionSummary; + value?: T; +} + +export interface MergedSessionCopy extends SessionCopy { + historyIncomplete: boolean; +} + +function mergeKey(copy: SessionCopy, index: number): string { + const peer = copy.summary.peerFingerprint?.trim(); + if (!peer) { + // 没有发起端指纹时不能只按 SessionID 合并:不同发起端可合法地产生同号会话。 + return `unmergeable\u0000${copy.sourceFingerprint}\u0000${copy.summary.sessionId}\u0000${index}`; + } + return `${peer}\u0000${copy.summary.sessionId}`; +} + +/** + * R20:按(发起端指纹,SessionID)合并同一会话的多份清单摘要。 + * 桌面端持有完整标题与转录,因此无论输入顺序都优先;只有桌面端副本不在场时才 + * 保留 agentred 副本。agentred 副本属于某个已知桌面发起端时,明确标记历史不完整。 + */ +export function mergeSessionCopies( + copies: SessionCopy[], + desktopFingerprints: ReadonlySet = new Set(), +): MergedSessionCopy[] { + const byKey = new Map>(); + copies.forEach((copy, index) => { + const key = mergeKey(copy, index); + const current = byKey.get(key); + if ( + !current || + (copy.sourceKind === "desktop" && current.sourceKind !== "desktop") + ) { + byKey.set(key, copy); + } + }); + + return [...byKey.values()].map((copy) => ({ + ...copy, + historyIncomplete: + copy.sourceKind === "agentred" && + desktopFingerprints.has(copy.summary.peerFingerprint?.trim() ?? ""), + })); +} diff --git a/frontend/src/lib/sessionView.ts b/frontend/src/lib/sessionView.ts index 5c7c45b7..ecc97ae3 100644 --- a/frontend/src/lib/sessionView.ts +++ b/frontend/src/lib/sessionView.ts @@ -1,17 +1,18 @@ /** - * 会话视图状态(R11):四类不可达与失效各自可区分,不折叠成同一个错误。 + * 会话视图状态(R11):六类失败与失效各自可区分,不折叠成同一个错误。 * * 由纯函数 deriveSessionViewStatus 从底层信号导出,便于 vitest 直接构造各状态断言 - * 文案互不相同(测试接缝 6)。四类状态: - * - machineOffline —— 目标 agentred 离线(R11 第 1 类) - * - reconnecting / lost / connected —— 浏览器与 server 之间断线(R11 第 2 类, - * 按既有的 connected / reconnecting / lost 表达) - * - revoked —— 本浏览器已被解除授权,须重新登录(R11 第 3 类) - * - loggedOut —— 账号已登出(R11 第 4 类) + * 文案互不相同(测试接缝 6)。六类失败/失效状态: + * - machineOffline —— 目标 agentred 离线(既有措辞保持不变) + * - reconnecting / lost / connected —— 浏览器与 server 之间断线 + * - revoked —— 本浏览器已被解除授权,须重新登录 + * - loggedOut —— 账号已登出 + * - desktopAppNotRunning —— 目标桌面端存在,但 Agentre App 没运行 + * - pinnedAgentredUnavailable —— 桌面端在场且历史可读,但新写入无法送到钉住的 agentred * - * 优先级:账号登出 > 解除授权 > 机器离线 > 中继连接态。优先级由可探测性决定: - * 账号没了其余全无意义;设备被解除授权后任何注册 / 连接都不该继续;机器离线时 - * 中继必然连不上,没必要先演一段「连接中」。 + * 优先级:账号登出 > 解除授权 > 目标不可达 > 中继连接态 > 钉住的 agentred 不可写。 + * 账号没了其余全无意义;设备被解除授权后任何注册 / 连接都不该继续;目标不可达时 + * 中继必然连不上;钉住的 agentred 状态只在桌面中继仍连接时成立。 */ import type { RelayState } from "@/lib/relayClient"; @@ -21,6 +22,8 @@ export type SessionViewStatus = | "reconnecting" | "lost" | "machineOffline" + | "desktopAppNotRunning" + | "pinnedAgentredUnavailable" | "revoked" | "loggedOut"; @@ -31,17 +34,32 @@ export interface SessionViewInput { meValid: boolean; /** 本浏览器(kind=web 设备)是否已被解除授权。 */ webDeviceRevoked: boolean; - /** 目标 agentred 是否在线;null = 尚未探知。 */ + /** 目标设备是否在线;desktop=false 表示 App 没运行,agentred=false 表示机器离线。 */ machineOnline: boolean | null; + targetKind?: "agentred" | "desktop" | string; + /** 桌面端仍可读历史,但该会话钉住的 agentred 当前不可用于新写入。 */ + pinnedAgentredUnavailable?: boolean; } export function deriveSessionViewStatus( input: SessionViewInput, ): SessionViewStatus { - const { relayState, meValid, webDeviceRevoked, machineOnline } = input; + const { + relayState, + meValid, + webDeviceRevoked, + machineOnline, + targetKind, + pinnedAgentredUnavailable, + } = input; if (!meValid) return "loggedOut"; if (webDeviceRevoked) return "revoked"; - if (machineOnline === false) return "machineOffline"; + if (machineOnline === false) { + return targetKind === "desktop" ? "desktopAppNotRunning" : "machineOffline"; + } + if (relayState === "connected" && pinnedAgentredUnavailable) { + return "pinnedAgentredUnavailable"; + } switch (relayState) { case "connected": return "connected"; diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 706c7f8a..44f6f83f 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -19,6 +19,7 @@ import SessionDetailView from "@/components/session/SessionDetailView"; import { useIsMobile } from "@/components/use-is-mobile"; import { useRelayMachine } from "@/hooks/use-relay"; import { api, ApiError } from "@/lib/api"; +import { mergeSessionCopies } from "@/lib/sessionMerge"; import { decodeSessionListResult, type SessionSummary } from "@/lib/wire"; /** @@ -31,7 +32,7 @@ import { decodeSessionListResult, type SessionSummary } from "@/lib/wire"; * * 关注名单(/v1/follows)只含「指向」(设备指纹 + 会话标识 + 关注时间),不含 * 标题 / 消息 / 转录。要显示与 R5 同一套信息(标题 / 状态 / 等待输入),本页对 - * 每个在线的目标机器挂一个 FollowedMachineResolver:连上中继后 session.list, + * 每个在线的目标设备挂一个 FollowedMachineResolver:连上中继后 session.list, * 把关注到的会话解析出来。机器离线时该条仍在名单里并标明离线(R13);目标已 * 不存在(设备被撤销 / 机器上已没有这条会话)时标失效、可一键移除。 */ @@ -63,8 +64,8 @@ function loadErrorText(e: unknown, t: (k: string) => string): string { } /** - * 一台在线目标机器的会话解析器:用 useRelayMachine 连到那台 agentred,连上后 - * session.list,把属于本页关注集合的会话解析出来回传。每台在线机器一个实例。 + * 一台在线目标设备的会话解析器:用 useRelayMachine 连到桌面端或 agentred,连上后 + * session.list,把属于本页关注集合的会话解析出来回传。每台在线目标一个实例。 */ function FollowedMachineResolver({ fingerprint, @@ -233,9 +234,84 @@ export default function Chat() { const pending: ChatPendingRow[] = []; const offline: ChatOfflineRow[] = []; const invalid: ChatInvalidRow[] = []; + const desktopFingerprints = new Set( + devices + .filter((device) => device.kind === "desktop") + .map((device) => device.fingerprint), + ); + + const copies = follows.flatMap((follow) => { + const device = devicesByFp.get(follow.device_fingerprint); + if ( + follow.invalid || + !device?.online || + machineState[follow.device_fingerprint] === "unreachable" + ) { + return []; + } + const match = (resolved[follow.device_fingerprint] ?? []).find( + (session) => String(session.sessionId) === follow.session_id, + ); + return match + ? [ + { + sourceFingerprint: follow.device_fingerprint, + sourceKind: device.kind, + summary: match, + value: { follow, device }, + }, + ] + : []; + }); + const merged = mergeSessionCopies(copies, desktopFingerprints); + const resolvedFollowKeys = new Set( + copies.map( + (copy) => + `${copy.value.follow.device_fingerprint}:${copy.value.follow.session_id}`, + ), + ); + const selectedFollowKeys = new Set( + merged.map( + (copy) => + `${copy.value!.follow.device_fingerprint}:${copy.value!.follow.session_id}`, + ), + ); + const representedKeys = new Set( + merged.flatMap((copy) => { + const peer = copy.summary.peerFingerprint?.trim(); + return peer ? [`${peer}\u0000${copy.summary.sessionId}`] : []; + }), + ); + + for (const copy of merged) { + const { follow, device } = copy.value!; + const syncId = copy.summary.agentSyncId?.trim() ?? ""; + const row: ChatSessionRow = { + key: `${follow.device_fingerprint}:${follow.session_id}`, + fingerprint: follow.device_fingerprint, + sessionId: copy.summary.sessionId, + deviceId: device.id, + deviceName: device.name, + followedAt: follow.followed_at, + summary: copy.summary, + historyIncomplete: copy.historyIncomplete, + }; + let group: ChatGroup | undefined; + if (syncId) { + group = groupsByKey.get(syncId); + if (!group) { + group = { key: syncId, label: syncId, sessions: [] }; + groupsByKey.set(syncId, group); + } + } else { + group = unnamed; + } + group.sessions.push(row); + } for (const f of follows) { const key = `${f.device_fingerprint}:${f.session_id}`; + if (selectedFollowKeys.has(key) || resolvedFollowKeys.has(key)) continue; const device = devicesByFp.get(f.device_fingerprint); if (f.invalid || !device) { invalid.push({ @@ -247,6 +323,13 @@ export default function Chat() { }); continue; } + // agentred 退化副本已经代表了同一条桌面会话时,不再额外画一条桌面离线/等待行。 + if ( + device.kind === "desktop" && + representedKeys.has(`${device.fingerprint}\u0000${f.session_id}`) + ) { + continue; + } if ( !device.online || machineState[f.device_fingerprint] === "unreachable" @@ -257,48 +340,23 @@ export default function Chat() { sessionId: Number(f.session_id), deviceId: device.id, deviceName: device.name, + deviceKind: device.kind, lastSeenAt: device.last_seen_at, }); continue; } - const sessList = resolved[f.device_fingerprint] ?? []; - const match = sessList.find((s) => String(s.sessionId) === f.session_id); - if (!match) { - if (machineState[f.device_fingerprint] === "connected") { - // 已连上但机器上已没有这条会话(如 daemon 清理了记录)→ 失效可移除。 - invalid.push({ - key, - fingerprint: f.device_fingerprint, - sessionId: Number(f.session_id), - deviceName: device.name, - reason: "session", - }); - } else { - pending.push({ key, deviceName: device.name }); - } - continue; - } - const syncId = match.agentSyncId?.trim() ?? ""; - const row: ChatSessionRow = { - key, - fingerprint: f.device_fingerprint, - sessionId: match.sessionId, - deviceId: device.id, - deviceName: device.name, - followedAt: f.followed_at, - summary: match, - }; - let group: ChatGroup | undefined; - if (syncId) { - group = groupsByKey.get(syncId); - if (!group) { - group = { key: syncId, label: syncId, sessions: [] }; - groupsByKey.set(syncId, group); - } + if (machineState[f.device_fingerprint] === "connected") { + // 已连上但机器上已没有这条会话(如 daemon 清理了记录)→ 失效可移除。 + invalid.push({ + key, + fingerprint: f.device_fingerprint, + sessionId: Number(f.session_id), + deviceName: device.name, + reason: "session", + }); } else { - group = unnamed; + pending.push({ key, deviceName: device.name }); } - group.sessions.push(row); } const groups = [...groupsByKey.values()]; @@ -312,7 +370,7 @@ export default function Chat() { const total = groups.reduce((n, g) => n + g.sessions.length, 0) + offline.length; return { groups, pending, offline, invalid, total }; - }, [follows, devicesByFp, agents, resolved, machineState, t]); + }, [follows, devices, devicesByFp, agents, resolved, machineState, t]); if (loadError) { return ( @@ -543,7 +601,7 @@ export default function Chat() { } /> - {/* 每个在线且有非失效关注的机器挂一个解析器(见文件头注释)。 */} + {/* 每个在线且有非失效关注的目标设备挂一个解析器(见文件头注释)。 */} {loaded && [...devicesByFp.values()] .filter( diff --git a/frontend/src/pages/DeviceSessions.tsx b/frontend/src/pages/DeviceSessions.tsx index 1b120cad..375dd9db 100644 --- a/frontend/src/pages/DeviceSessions.tsx +++ b/frontend/src/pages/DeviceSessions.tsx @@ -56,7 +56,7 @@ export default function DeviceSessions() { const nav = useNavigate(); const [device, setDevice] = useState(null); - // 账号下的全部 agentred:面包屑行尾的「换机器」就地切换(决策 11 / 帧 45a)要用。 + // 账号下全部可下钻目标:面包屑行尾的「换机器」就地切换要用。 const [machines, setMachines] = useState([]); const [switchOpen, setSwitchOpen] = useState(false); const [deviceError, setDeviceError] = useState(null); @@ -72,7 +72,7 @@ export default function DeviceSessions() { const probedRef = useRef(false); const { client, relayState, webDevice, webDeviceError } = useRelayMachine( - device?.fingerprint ?? null, + device?.online ? device.fingerprint : null, ); const isMobile = useIsMobile(); @@ -85,8 +85,12 @@ export default function DeviceSessions() { ]) .then(([devRes, agentRes]) => { if (!alive) return; - // 浏览器不是可下钻的目标(R3 / 帧 47),只留 agentred。 - setMachines(devRes.devices.filter((d) => d.kind === "agentred")); + // 浏览器不是可下钻目标;agentred 与桌面端复用同一套列表/详情页面。 + setMachines( + devRes.devices.filter( + (d) => d.kind === "agentred" || d.kind === "desktop", + ), + ); const found = devRes.devices.find((d) => d.id === id); if (found) { setDevice(found); @@ -137,9 +141,10 @@ export default function DeviceSessions() { if (!alive) return; const res = decodeSessionListResult(raw); setSessions(res.sessions); - // 老 agentred 不认识这个字段 → 它落库不了 R7 / 决策 8 的那几列,会话只能 - // 退化显示、发新消息也续不上上下文。如实说明,不静默失败。 - setNeedsUpgrade(!res.supportsSessionMetadata); + // 老 agentred 不认识这个字段;桌面端清单由本轮实现,始终是完整形态。 + setNeedsUpgrade( + device?.kind === "agentred" && !res.supportsSessionMetadata, + ); setSessionsLoaded(true); }) .catch(() => { @@ -148,7 +153,7 @@ export default function DeviceSessions() { return () => { alive = false; }; - }, [client, relayState]); + }, [client, relayState, device?.kind]); // 首次进入 reconnecting(= 连接失败)时探测原因:机器离线 / 设备被解除授权 / // 账号登出。只探一次(ref 守门,不触发重渲染)。 @@ -219,6 +224,7 @@ export default function DeviceSessions() { meValid, webDeviceRevoked: revokedNow, machineOnline, + targetKind: device?.kind, }); if (deviceError) { @@ -256,7 +262,11 @@ export default function DeviceSessions() { } > {machineOnline === false - ? t("session.breadcrumb.offline") + ? t( + device?.kind === "desktop" + ? "device.manage.desktopAppNotRunningShort" + : "session.breadcrumb.offline", + ) : t("session.breadcrumb.online")} @@ -276,7 +286,11 @@ export default function DeviceSessions() { } > {machineOnline === false - ? t("session.breadcrumb.offline") + ? t( + device?.kind === "desktop" + ? "device.manage.desktopAppNotRunningShort" + : "session.breadcrumb.offline", + ) : t("session.breadcrumb.online")} {count > 0 && ( @@ -328,7 +342,11 @@ export default function DeviceSessions() { {m.name} - {t("session.breadcrumb.offline")} + {t( + m.kind === "desktop" + ? "device.manage.desktopAppNotRunningShort" + : "session.breadcrumb.offline", + )} )} diff --git a/frontend/src/pages/Devices.tsx b/frontend/src/pages/Devices.tsx index 7b2dea33..365019d7 100644 --- a/frontend/src/pages/Devices.tsx +++ b/frontend/src/pages/Devices.tsx @@ -66,6 +66,7 @@ interface SessionCounts { const ACTIVE = 1; const KIND_AGENTRED = "agentred"; +const KIND_DESKTOP = "desktop"; // 只有 ApiError 才带可展示的服务端文案;其余(代理返回非 JSON 的 502 → SyntaxError、 // 离线 → TypeError)同样是失败,必须说出来 —— 静默吞掉会让页面渲染成「还没有任何 @@ -98,7 +99,9 @@ function statusLabel(d: DeviceItem, t: (key: string) => string): string { if (d.status !== ACTIVE) return t("device.manage.statusRevoked"); return d.online ? t("device.manage.statusOnline") - : t("device.manage.statusOffline"); + : d.kind === KIND_DESKTOP + ? t("device.manage.desktopAppNotRunningShort") + : t("device.manage.statusOffline"); } /** 设备是否可撤销:撤销端点只认属于本账号的 ACTIVE 设备;已撤销的没有凭据可撤。 */ @@ -107,9 +110,9 @@ function isRevocable(d: DeviceItem): boolean { } /** - * 一台 agentred 的会话计数(总条数 / 等待处理 / 在跑)。server 一条会话都不存 - * (硬不变量),唯一真相源是那台 agentred 的 relay session.list —— 现连现问, - * 展开时才挂载,收起即断开;问不到就返回 null,调用方不得编数字。 + * 一台目标设备(桌面端或 agentred)的会话计数(总条数 / 等待处理 / 在跑)。 + * server 一条会话都不存(硬不变量),唯一真相源是那台设备的 relay session.list —— + * 现连现问,展开时才挂载,收起即断开;问不到就返回 null,调用方不得编数字。 * * 「在跑」数是设备卡副行 cardSummary 的 {{m}};「对话」一节用 total/waiting。 */ @@ -146,10 +149,10 @@ function useSessionCounts( /** * 设备行展开的详情:agentred 列「能跑的 Agent」(带档位)、「已配置的项目」与 - * 「对话」一节(R4 下钻入口,mockup 帧 47);桌面端只列「项目」。 - * 对话一节:在线的 agentred 给「查看这台机器的对话」入口;离线的不可进入, - * 就地标明离线与最后在线时间(R4)。会话计数由外层 useSessionCounts 提供, - * 这里不再各自连一次中继。 + * 「对话」一节(R4 下钻入口,mockup 帧 47);桌面端列项目。 + * 对话一节:两类目标复用同形入口 —— 在线给「查看这台机器 / 这个桌面端的对话」; + * 不可达时 agentred 保留既有离线措辞,桌面端明确说明 Agentre App 没有运行并提示打开它。 + * 会话计数由外层 useSessionCounts 提供,这里不再各自连一次中继。 * * 只渲染真实数据;N1/N2 旁白(「只显示是否配置」「Agent 属于账号」)不进入产品。 */ @@ -177,6 +180,7 @@ function DeviceExpandDetail({ const detail = state.data; if (!detail) return null; const isAgentred = detail.kind === KIND_AGENTRED; + const isDesktop = detail.kind === KIND_DESKTOP; return (
@@ -237,7 +241,7 @@ function DeviceExpandDetail({
)} - {isAgentred && ( + {(isAgentred || isDesktop) && (
{t("device.manage.sessions")} @@ -263,12 +267,20 @@ function DeviceExpandDetail({ data-testid={`device-sessions-link-${device.id}`} className="inline-flex w-fit items-center text-xs font-medium text-primary-text hover:underline" > - {t("device.manage.viewSessions")} + {t( + isDesktop + ? "device.manage.viewDesktopSessions" + : "device.manage.viewSessions", + )} ) : (

- {t("device.manage.offlineNotEnterable")} + {t( + isDesktop + ? "device.manage.desktopAppNotRunningDetail" + : "device.manage.offlineNotEnterable", + )} {device.last_seen_at > 0 ? ` ${new Date(device.last_seen_at).toLocaleString()}` : ""} @@ -316,8 +328,11 @@ function DeviceRow({ detailState: { loading: boolean; error: unknown; data: DeviceDetail | null }; t: (key: string, opts?: Record) => string; }) { - // 只有展开的在线 agentred 才去问中继;其余设备 fingerprint 传 null,不连。 - const sessionActive = isExpanded && d.kind === KIND_AGENTRED && d.online; + // 只有展开的在线 agentred / 桌面端才去问中继;其余设备 fingerprint 传 null,不连。 + const sessionActive = + isExpanded && + (d.kind === KIND_AGENTRED || d.kind === KIND_DESKTOP) && + d.online; const counts = useSessionCounts( sessionActive ? d.fingerprint : null, sessionActive, From 54f5e88f3fcca9a59e86a662de66a70844eece33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 12 Aug 2026 00:23:44 +0800 Subject: [PATCH 3/5] =?UTF-8?q?=F0=9F=A7=AA=20=E5=89=8D=E7=AB=AF:=20?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=A1=8C=E9=9D=A2=E7=AB=AF=E6=9C=AA=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E7=8A=B6=E6=80=81=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/__tests__/device-management.test.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/device-management.test.tsx b/frontend/src/__tests__/device-management.test.tsx index 0d01a034..dd30edbc 100644 --- a/frontend/src/__tests__/device-management.test.tsx +++ b/frontend/src/__tests__/device-management.test.tsx @@ -95,11 +95,13 @@ describe("device management page", () => { expect(screen.getByText(/linux/)).toBeTruthy(); expect(screen.getByText(/darwin/)).toBeTruthy(); // 在线态列来自 API 的 online 字段(真实中继在线态),不是 status 推算(R20): - // 逐行按 API 给的 online 值断言,而不是全页面找一个 "Online" 就算数。 + // 逐行断言;desktop 离线要表达 App 未运行,而不是笼统的机器离线。 const nucCard = screen.getByText("nuc-01").closest('[data-slot="card"]'); const laptopCard = screen.getByText("laptop").closest('[data-slot="card"]'); expect(within(nucCard as HTMLElement).getByText(/Online/)).toBeTruthy(); - expect(within(laptopCard as HTMLElement).getByText(/Offline/)).toBeTruthy(); + expect( + within(laptopCard as HTMLElement).getByText(/Agentre is not running/), + ).toBeTruthy(); }); it("revoke confirmation carries the R4 delay note, then revokes and removes the device", async () => { From 3e3d06cc4aa33ce721f5684b774f0ba39395470f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E4=B8=80=E4=B9=8B?= Date: Wed, 12 Aug 2026 12:49:38 +0800 Subject: [PATCH 4/5] =?UTF-8?q?=E2=9C=A8=20web=20=E6=B4=BE=E5=8F=91?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E6=94=AF=E6=8C=81=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E5=B9=B6=E5=A6=82=E5=AE=9E=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=AF=E7=94=A8=E6=80=A7=EF=BC=88=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=209=20R17=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WebDispatchPlan 为桌面端档解析本机上报路径(device_local_paths), 不再误判 project_path_missing,cwd 用桌面端自己的路径;Tier/Choice 带 kind。 - 发起前(确认步)按目标种类如实说明 org/subagent/hook 是否可用:桌面端可用, agentred 不可用(轮 A R17 原样成立)。 - 测试:桌面端首档可用取本机路径并带 kind、桌面端缺项目路径逐档原因并跳过、 dispatch/NewConversation 双态文案。 --- frontend/src/__tests__/dispatch.test.ts | 80 +++++++++++++ .../src/__tests__/new-conversation.test.tsx | 60 ++++++++++ .../session/NewConversationDialog.tsx | 38 +++--- frontend/src/i18n/locales/en.json | 2 + frontend/src/i18n/locales/zh-CN.json | 2 + frontend/src/lib/dispatch.ts | 4 + internal/api/workspace/workspace.go | 7 +- .../controller/workspace_ctr/workspace.go | 4 +- internal/service/workspace_svc/workspace.go | 52 ++++++++- .../service/workspace_svc/workspace_test.go | 108 ++++++++++++++++++ 10 files changed, 337 insertions(+), 20 deletions(-) diff --git a/frontend/src/__tests__/dispatch.test.ts b/frontend/src/__tests__/dispatch.test.ts index 74ac651e..3fdadbe7 100644 --- a/frontend/src/__tests__/dispatch.test.ts +++ b/frontend/src/__tests__/dispatch.test.ts @@ -53,6 +53,7 @@ const availablePlan: DispatchPlan = { device_id: 21, device_name: "公司 Mac mini", backend_type: "codex", + kind: "agentred", availability: "available", current: true, }, @@ -62,11 +63,38 @@ const availablePlan: DispatchPlan = { device_id: 21, device_name: "公司 Mac mini", backend_type: "codex", + kind: "agentred", cwd: "/srv/agentre-server", }, projects: [], }; +// R17:第一档可用的是桌面端时,派发计划选中它(kind=desktop),发起前界面据此 +// 说明 org/subagent/hook 可用(见 NewConversationDialog)。逻辑层照常用 runtime.run +// 把对话建到那台桌面端上,不注入 mcpServers。 +const desktopPlan: DispatchPlan = { + ...availablePlan, + tiers: [ + { + rank: 1, + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + availability: "available", + current: true, + }, + ], + chosen: { + device_fingerprint: "fp-desk", + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + cwd: "/Users/wyz/agentre-server", + }, +}; + const allUnavailablePlan: DispatchPlan = { agent_sync_id: "agent-1", tiers: [ @@ -219,6 +247,58 @@ describe("dispatchNewConversation(R15 派发 + R16 自关注)", () => { expect(mockedApi).not.toHaveBeenCalled(); }); + // R17:目标是桌面端时,同一套 runtime.run 把对话建到那台桌面端上——带着第一句、 + // 发起方身份与那台机器上的项目路径;不注入 mcpServers(org/subagent/hook 是桌面端 + // 本机内置工具,可用性由发起前的界面按 kind=desktop 如实说明,不由浏览器注入)。 + it("目标是桌面端时向桌面端发 runtime.run 建会话(不注入 mcpServers)", async () => { + const client = fakeClient(); + MockRelayClient.mockImplementation((() => client) as never); + + const out = await dispatchNewConversation({ + plan: desktopPlan, + message: "帮我看看这个项目", + sourceDevice, + }); + + const constructorOpts = MockRelayClient.mock.calls[0][0] as { + url: string; + jwt: string; + reconnect: boolean; + }; + expect(constructorOpts.url).toContain("fp-desk"); + expect(constructorOpts.jwt).toBe("web-jwt"); + expect(constructorOpts.reconnect).toBe(false); + + const [method, params] = client.request.mock.calls[0]; + const p = params as Record; + expect(method).toBe(MethodRun); + expect(p.sessionId).toBeGreaterThan(0); + expect(p.agentSyncId).toBe("agent-1"); + expect(p.cwd).toBe("/Users/wyz/agentre-server"); + expect(p.userText).toBe("帮我看看这个项目"); + expect(p.title).toBe("帮我看看这个项目"); + expect(p.backend).toEqual({ type: "claudecode" }); + expect(p.sourceDevice).toBe("fp-web"); + expect(p.sourceDeviceName).toBe("Chrome · macOS"); + expect(p.mcpServers).toBeUndefined(); + + // R16:桌面端建会话成功后同样立刻关注自己这条。 + expect(mockedApi).toHaveBeenCalledWith("/v1/follows", { + method: "POST", + body: JSON.stringify({ + device_fingerprint: "fp-desk", + session_id: "9001", + }), + }); + + expect(out).toEqual({ + sessionId: 9001, + deviceId: 30, + deviceFingerprint: "fp-desk", + }); + expect(client.close).toHaveBeenCalled(); + }); + // R16 的自关注是**派发成功之后**的收尾动作:runtime.run 一旦返回 ack,那台机器上 // 就已经真真切切多了一条会话。此时把关注写失败(网络抖动 / server 500)报成派发 // 失败,界面会说「联系不上这台机器,请重试」——用户一重试就凭空又开一条真会话, diff --git a/frontend/src/__tests__/new-conversation.test.tsx b/frontend/src/__tests__/new-conversation.test.tsx index 2423dc89..c66e8cc1 100644 --- a/frontend/src/__tests__/new-conversation.test.tsx +++ b/frontend/src/__tests__/new-conversation.test.tsx @@ -99,10 +99,36 @@ const finalPlan: DispatchPlan = { device_id: 21, device_name: "公司 Mac mini", backend_type: "codex", + kind: "agentred", cwd: "/srv/agentre-server", }, }; +// R17:目标是桌面端时,发起前如实说明 org / subagent / hook **可用**(真身在桌面端)。 +const desktopFinalPlan: DispatchPlan = { + agent_sync_id: "agent-1", + tiers: [ + { + rank: 1, + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + availability: "available", + current: true, + }, + ], + chosen: { + device_fingerprint: "fp-desk", + device_id: 30, + device_name: "家里 Mac mini", + backend_type: "claudecode", + kind: "desktop", + cwd: "/Users/wyz/agentre-server", + }, + projects: [{ sync_id: "proj-1", name: "agentre-server", configured: true }], +}; + const allUnavailablePlan: DispatchPlan = { agent_sync_id: "agent-1", tiers: [ @@ -248,6 +274,40 @@ describe("新对话弹层:R17 发起前说明 + R16 派发后自关注", () => { ); }); + it("目标是桌面端时,确认步如实说明 org / subagent / hook 可用(不是沿用 agentred 的不可用文案)", async () => { + mockedApi.mockImplementation(async (path) => { + if (path === "/v1/workspace/agents") return { agents }; + throw new Error("unexpected: " + path); + }); + mockFetchPlan.mockImplementation(async (_agent, project) => + project ? desktopFinalPlan : pickPlan, + ); + mockEnsureWebDevice.mockResolvedValue(webDevice); + mockDispatch.mockResolvedValue({ + sessionId: 9002, + deviceId: 30, + deviceFingerprint: "fp-desk", + }); + const onStarted = vi.fn(); + renderDialog(onStarted); + + fireEvent.click(await screen.findByText("后端 Agent")); + fireEvent.click(await screen.findByText("agentre-server")); + + // R17:目标是桌面端 → org / subagent / hook 可用(真身就在那台机器上)。 + expect( + await screen.findByText("org / subagent / hook are available here"), + ).toBeTruthy(); + // 不可用的文案不得出现。 + expect( + screen.queryByText("org / subagent / hook are not available here"), + ).toBeNull(); + // 屏 25:将运行在桌面端机器 · 路径。 + expect( + screen.getByText("Will run on 家里 Mac mini · /Users/wyz/agentre-server"), + ).toBeTruthy(); + }); + it("不输入第一句时「开始」按钮是禁用的(发出第一条消息之前什么都不会跑)", async () => { mockedApi.mockImplementation(async (path) => { if (path === "/v1/workspace/agents") return { agents }; diff --git a/frontend/src/components/session/NewConversationDialog.tsx b/frontend/src/components/session/NewConversationDialog.tsx index 48fe51ce..f89a31bb 100644 --- a/frontend/src/components/session/NewConversationDialog.tsx +++ b/frontend/src/components/session/NewConversationDialog.tsx @@ -6,8 +6,9 @@ * - R15:派发计划(/v1/workspace/dispatch-target)按序列出执行目标链每一档的 * 原因(本机跳过 / 未配对 / 离线 / 项目路径缺失),只有第一档可用的 agentred * 才能继续;全部不可用时不静默失败,逐档把原因摆给用户(屏 24 的「现在选不了」)。 - * - R17:发起前就在确认步说明 org / subagent / hook 用不了以及原因——不是等 - * 工具调用失败了才报错。 + * - R17:发起前就在确认步说明 org / subagent / hook 在当前目标下是否可用——目标是 + * 桌面端时这三个内置工具的真身就在那台机器上、可用;是 agentred 时轮 A 的 R17 + * 原样成立、不可用。不是等工具调用失败了才报错。 * - R16:确认后经 lib/dispatch.dispatchNewConversation 派发并立刻关注自己这条, * 于是它不经「关注」就出现在「对话」页。 */ @@ -360,18 +361,27 @@ export default function NewConversationDialog({ })}

)} - {/* R17:发起前就说明 org / subagent / hook 用不了以及原因。 */} -
-

- {t("chat.r17Title")} -

-

- {t("chat.r17Body")} -

-
+ {/* R17:发起前如实说明当前目标下 org / subagent / hook 是否可用——目标是 + 桌面端时这三个内置工具的真身就在那台机器上、可用;是 agentred 时 + 轮 A 的 R17 原样成立、不可用。不是等工具调用失败了才报错。 */} + {confirmChoice && ( +
+

+ {confirmChoice.kind === "desktop" + ? t("chat.r17DesktopTitle") + : t("chat.r17Title")} +

+

+ {confirmChoice.kind === "desktop" + ? t("chat.r17DesktopBody") + : t("chat.r17Body")} +

+
+ )}