diff --git a/e2e/run-e2e-web.mjs b/e2e/run-e2e-web.mjs
index 738dc82c..9d0ff10e 100644
--- a/e2e/run-e2e-web.mjs
+++ b/e2e/run-e2e-web.mjs
@@ -173,9 +173,9 @@ async function main() {
// 播种的项目在这台 agentred 上的真实目录(= 新对话的 cwd)。它是 workDir 下的
// 一个子目录,run 结束随 workDir 一起消失。
mkdirSync(projectDir, { recursive: true });
- // 桌面端的设备指纹与 bootstrap.newBootFingerprint 同形(16 字节 hex),它同时是
- // 桌面端在 agentred 上的对端身份 —— 会话归属的前半段。
- const desktopFP = randomUUID().replace(/-/g, "");
+ // 桌面端 identity 与 R12/R13 的 backend DeviceID 使用同一个 canonical
+ // fingerprint。server 设备行、Wails keychain 与会话 peer identity 全部透传此值。
+ const desktopFP = desktopFingerprint(randomUUID().replace(/-/g, ""));
seeded = runTool([
"seed",
"--dsn",
@@ -432,10 +432,18 @@ function reapOrphanVite(dir) {
// ── pieces ──────────────────────────────────────────────────────────────────
-function daemonFingerprint(instanceUUID) {
+function canonicalFingerprint(instanceUUID) {
return `sha256:${createHash("sha256").update(instanceUUID).digest("hex")}`;
}
+function daemonFingerprint(instanceUUID) {
+ return canonicalFingerprint(instanceUUID);
+}
+
+export function desktopFingerprint(instanceUUID) {
+ return canonicalFingerprint(instanceUUID);
+}
+
function locateAgentreCheckout(serverDir) {
const override = process.env.AGENTRE_DIR;
if (override) {
diff --git a/e2e/web/runner-config.spec.ts b/e2e/web/runner-config.spec.ts
index 65b63203..8f626158 100644
--- a/e2e/web/runner-config.spec.ts
+++ b/e2e/web/runner-config.spec.ts
@@ -24,6 +24,7 @@ import {
parseMySQLAddress,
parseRedis,
summarizeStartupFailure,
+ desktopFingerprint,
} from "../run-e2e-web.mjs";
/** 造一个和真 checkout 同形的目录:configs/ + runtime/keys/ 下的真密钥文件。 */
@@ -96,6 +97,17 @@ const ROTATION = `server:
issuer: "agentre-server"
`;
+test("dual runner 给桌面端生成与 R12/R13 一致的 canonical fingerprint", () => {
+ const instanceUUID = "0123456789abcdef0123456789abcdef";
+
+ const fingerprint = desktopFingerprint(instanceUUID);
+
+ expect(fingerprint).toMatch(/^sha256:[0-9a-f]{64}$/);
+ expect(fingerprint).toBe(
+ "sha256:3eb1bd439947eb762998e566ccc2e099c791118b2f40579cc4f7da2b5061b7f9",
+ );
+});
+
test("带引号的 JWT 路径改写成真实存在的绝对路径,而不是把引号拼进路径", () => {
const dir = fakeCheckout(["jwt.key", "jwt.pub"]);
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-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 () => {
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__/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/__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/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")}
+
+
+ )}
)}
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,
diff --git a/internal/api/workspace/workspace.go b/internal/api/workspace/workspace.go
index 036b0ddd..b56b14ce 100644
--- a/internal/api/workspace/workspace.go
+++ b/internal/api/workspace/workspace.go
@@ -52,6 +52,9 @@ type DispatchTierItem struct {
DeviceID int64 `json:"device_id,omitempty"`
DeviceName string `json:"device_name,omitempty"`
BackendType string `json:"backend_type,omitempty"`
+ // Kind 是这一档指向的设备种类(desktop / agentred)。R17 发起前据此如实说明
+ // org/subagent/hook 在目标上是否可用;无设备的档(本机相对 / 未配对)不带它。
+ Kind string `json:"kind,omitempty"`
// Availability 是 available / offline / unpaired / skipped_for_web /
// project_path_missing 之一。
Availability string `json:"availability"`
@@ -66,7 +69,9 @@ type DispatchChoiceItem struct {
DeviceID int64 `json:"device_id"`
DeviceName string `json:"device_name"`
BackendType string `json:"backend_type"`
- Cwd string `json:"cwd,omitempty"`
+ // Kind 是选中目标设备种类(desktop / agentred),R17 发起前据此说明工具可用性。
+ Kind string `json:"kind"`
+ Cwd string `json:"cwd,omitempty"`
}
type DispatchTargetResponse struct {
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/controller/workspace_ctr/workspace.go b/internal/controller/workspace_ctr/workspace.go
index 29aa95b7..30a04a30 100644
--- a/internal/controller/workspace_ctr/workspace.go
+++ b/internal/controller/workspace_ctr/workspace.go
@@ -57,7 +57,8 @@ func (w *Workspace) DispatchTarget(c *gin.Context, req *api.DispatchTargetReques
for _, t := range plan.Tiers {
resp.Tiers = append(resp.Tiers, api.DispatchTierItem{
Rank: t.Rank, DeviceID: t.DeviceID, DeviceName: t.DeviceName,
- BackendType: t.BackendType, Availability: t.Availability, Current: t.Current,
+ BackendType: t.BackendType, Kind: t.Kind,
+ Availability: t.Availability, Current: t.Current,
})
}
if plan.Chosen != nil {
@@ -66,6 +67,7 @@ func (w *Workspace) DispatchTarget(c *gin.Context, req *api.DispatchTargetReques
DeviceID: plan.Chosen.DeviceID,
DeviceName: plan.Chosen.DeviceName,
BackendType: plan.Chosen.BackendType,
+ Kind: plan.Chosen.Kind,
Cwd: plan.Chosen.Cwd,
}
}
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)
})
diff --git a/internal/service/workspace_svc/workspace.go b/internal/service/workspace_svc/workspace.go
index 5ce46f07..d0c1e8d5 100644
--- a/internal/service/workspace_svc/workspace.go
+++ b/internal/service/workspace_svc/workspace.go
@@ -89,11 +89,14 @@ type DeviceDetailView struct {
// WebDispatchTier 是 R15 派发计划里执行目标链上的一档:从 web 给「某 Agent +
// 某项目」派活时,这一档为什么能用 / 不能用。Availability 取值见常量。
+// Kind 是这一档指向的设备种类(device_entity.KindDesktop / KindAgentred)——R17
+// 发起前要按它如实说明 org/subagent/hook 在目标上是否可用。
type WebDispatchTier struct {
Rank int
DeviceID int64
DeviceName string
BackendType string
+ Kind string
Availability string
// Current 标记按顺序取第一个可用的会落到这一档。至多一档为 true。
Current bool
@@ -112,7 +115,10 @@ type WebDispatchChoice struct {
DeviceID int64
DeviceName string
BackendType string
- Cwd string
+ // Kind 是选中目标设备种类(device_entity.KindDesktop / KindAgentred),R17
+ // 发起前据此说明三个内置工具是否可用。
+ Kind string
+ Cwd string
}
// WebDispatchPlan 是「从 web 给某 Agent + 某项目派活」的完整计划:有序逐档说明 +
@@ -448,6 +454,31 @@ func (s *workspaceSvc) WebDispatchPlan(
return v
}
+ // 每个设备「配了哪些项目的路径」:agentred 的路径在同步组 project_location(跟着
+ // 账号在桌面端之间流动,决策 7);桌面端的本机路径不流动(决策 6),只存在于上报组
+ // device_local_paths(按上报设备分命名空间)。两者不能混用,按设备种类各取各的。
+ desktopLocations := map[int64]map[string]string{}
+ locationsFor := func(dev *device_entity.Device) (map[string]string, error) {
+ if dev.Kind != device_entity.KindDesktop {
+ return locationsByFP[dev.Fingerprint], nil
+ }
+ if m, ok := desktopLocations[dev.ID]; ok {
+ return m, nil
+ }
+ rows, err := sync_repo.SyncLocalPath().ListByDevice(ctx, userID, dev.ID)
+ if err != nil {
+ return nil, err
+ }
+ m := make(map[string]string, len(rows))
+ for _, row := range rows {
+ if row != nil && row.ProjectSyncID != "" {
+ m[row.ProjectSyncID] = row.Path
+ }
+ }
+ desktopLocations[dev.ID] = m
+ return m, nil
+ }
+
chains := buildAgentChains(rows)
var chain *agentChain
for i := range chains {
@@ -462,6 +493,9 @@ func (s *workspaceSvc) WebDispatchPlan(
plan := &WebDispatchPlan{AgentSyncID: agentSyncID}
currentAssigned := false
+ // chosenCwd 是选中档(第一个可用)所选项目在那台机器上的绝对路径;case 块内
+ // 变量出不了 switch,先摆在这里由选中档写入。
+ var chosenCwd string
for _, t := range chain.Targets {
tier := WebDispatchTier{Rank: t.Rank, BackendType: t.BackendType}
switch {
@@ -478,17 +512,23 @@ func (s *workspaceSvc) WebDispatchPlan(
}
tier.DeviceID = dev.ID
tier.DeviceName = dev.Name
+ tier.Kind = dev.Kind
if !dev.IsActive() || !isOnline(t.Fingerprint) {
tier.Availability = AvailabilityOffline
break
}
+ locations, lerr := locationsFor(dev)
+ if lerr != nil {
+ return nil, lerr
+ }
if projectSyncID != "" {
- if _, configured := locationsByFP[t.Fingerprint][projectSyncID]; !configured {
+ if _, configured := locations[projectSyncID]; !configured {
tier.Availability = AvailabilityProjectPathMissing
break
}
}
tier.Availability = AvailabilityAvailable
+ chosenCwd = locations[projectSyncID]
}
if !currentAssigned && tier.Availability == AvailabilityAvailable {
tier.Current = true
@@ -498,17 +538,21 @@ func (s *workspaceSvc) WebDispatchPlan(
DeviceID: tier.DeviceID,
DeviceName: tier.DeviceName,
BackendType: tier.BackendType,
+ Kind: tier.Kind,
}
// 选中档的 cwd:所选项目在这台机器上的绝对路径(见 WebDispatchChoice.Cwd
// 注释,这是 R19 在主动派活场景下的唯一例外)。未选项目时留空。
- plan.Chosen.Cwd = locationsByFP[t.Fingerprint][projectSyncID]
+ plan.Chosen.Cwd = chosenCwd
}
plan.Tiers = append(plan.Tiers, tier)
}
// picker 用:选中的那一档机器上已配置的项目清单(按 sync_id 排序稳定)。
if plan.Chosen != nil {
- configured := locationsByFP[plan.Chosen.DeviceFingerprint]
+ configured, cerr := locationsFor(deviceByFP[plan.Chosen.DeviceFingerprint])
+ if cerr != nil {
+ return nil, cerr
+ }
ids := make([]string, 0, len(configured))
for id := range configured {
ids = append(ids, id)
diff --git a/internal/service/workspace_svc/workspace_test.go b/internal/service/workspace_svc/workspace_test.go
index b08b5219..11a6252c 100644
--- a/internal/service/workspace_svc/workspace_test.go
+++ b/internal/service/workspace_svc/workspace_test.go
@@ -426,3 +426,111 @@ func TestWebDispatchPlan_GivenUnknownAgent_ThenNotFound(t *testing.T) {
_, err := svc.WebDispatchPlan(ctx, 7, "agent-missing", "")
assert.Error(t, err)
}
+
+// R17:浏览器把新对话派到一台桌面端上。桌面端在派发计划里是与 agentred 同地位的
+// 具名目标(决策 10)——第一档可用的是桌面端时,计划必须选中它并把「该桌面端自己
+// 上报的本机路径」作为 cwd 带出(决策 6:桌面端路径只存在于上报组 device_local_paths,
+// 不在同步组 project_location 里),而不是误判成 project_path_missing。选中的档还要
+// 携带 kind=desktop,供发起前如实说明 org/subagent/hook 在桌面端上可用(R17)。
+func TestWebDispatchPlan_GivenDesktopFirstAvailable_ThenChoosesDesktopWithLocalPathAndKind(t *testing.T) {
+ ctx, mObj, mPath, mDev, svc := setupWorkspaceTest(t)
+ SetOnlineChecker(fakeOnlineChecker{online: map[string]bool{"fp-desk": true, "fp-agentred": false}})
+
+ mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return([]*sync_entity.SyncObject{
+ {Kind: sync_entity.KindAgent, SyncID: "agent-1", Payload: mustJSON(t, map[string]any{"name": "后端 Agent"})},
+ {Kind: sync_entity.KindProject, SyncID: "proj-1", Payload: mustJSON(t, map[string]any{"name": "agentre-server"})},
+ {Kind: sync_entity.KindAgentBackend, SyncID: "b-local", AgentredFingerprint: "",
+ Payload: mustJSON(t, map[string]any{"type": "claude_code"})},
+ {Kind: sync_entity.KindAgentBackend, SyncID: "b-desk", AgentredFingerprint: "fp-desk",
+ Payload: mustJSON(t, map[string]any{"type": "claude_code"})},
+ {Kind: sync_entity.KindAgentBackend, SyncID: "b-agentred", AgentredFingerprint: "fp-agentred",
+ Payload: mustJSON(t, map[string]any{"type": "codex"})},
+ {Kind: sync_entity.KindAgentExecTarget, SyncID: "t1",
+ Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-local", "sort_order": 0})},
+ {Kind: sync_entity.KindAgentExecTarget, SyncID: "t2",
+ Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-desk", "sort_order": 1})},
+ {Kind: sync_entity.KindAgentExecTarget, SyncID: "t3",
+ Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-agentred", "sort_order": 2})},
+ // 桌面端不写同步组 project_location;它的路径只在上报组 device_local_paths。
+ }, nil)
+ mDev.EXPECT().ListByUser(ctx, int64(7)).Return([]*device_entity.Device{
+ {ID: 20, UserID: 7, Name: "书房小主机", Kind: device_entity.KindAgentred, Fingerprint: "fp-agentred", Status: 1},
+ {ID: 30, UserID: 7, Name: "家里 Mac mini", Kind: device_entity.KindDesktop, Fingerprint: "fp-desk", Status: 1},
+ }, nil)
+ mPath.EXPECT().ListByDevice(ctx, int64(7), int64(30)).Return([]*sync_entity.DeviceLocalPath{
+ {UserID: 7, DeviceID: 30, ProjectSyncID: "proj-1", Path: "/Users/wyz/agentre-server"},
+ }, nil)
+
+ plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1")
+ require.NoError(t, err)
+ require.Len(t, plan.Tiers, 3)
+
+ // 本机相对引用照旧跳过(R15d 删除后无相对槽位,这里是最老档位的遗留形态)。
+ assert.Equal(t, AvailabilitySkippedForWeb, plan.Tiers[0].Availability)
+
+ // 第一档可用的是桌面端:被选中,且不是 project_path_missing(路径来自它自己的上报)。
+ assert.Equal(t, AvailabilityAvailable, plan.Tiers[1].Availability)
+ assert.Equal(t, "家里 Mac mini", plan.Tiers[1].DeviceName)
+ assert.Equal(t, device_entity.KindDesktop, plan.Tiers[1].Kind)
+ assert.True(t, plan.Tiers[1].Current)
+
+ assert.Equal(t, AvailabilityOffline, plan.Tiers[2].Availability)
+ assert.Equal(t, "书房小主机", plan.Tiers[2].DeviceName)
+
+ require.NotNil(t, plan.Chosen)
+ assert.Equal(t, "fp-desk", plan.Chosen.DeviceFingerprint)
+ assert.Equal(t, device_entity.KindDesktop, plan.Chosen.Kind)
+ assert.Equal(t, "家里 Mac mini", plan.Chosen.DeviceName)
+ assert.Equal(t, "/Users/wyz/agentre-server", plan.Chosen.Cwd)
+
+ // picker 用:桌面端已配置的项目清单同样从它的上报组路径来。
+ require.Len(t, plan.Projects, 1)
+ assert.Equal(t, "agentre-server", plan.Projects[0].Name)
+ assert.Equal(t, "proj-1", plan.Projects[0].SyncID)
+}
+
+// R17 的不可用边界:桌面端在线但没配所选项目的路径 → 该档如实标 project_path_missing
+// (逐档原因,不静默),继续按顺序取下一档配了路径的 agentred。
+func TestWebDispatchPlan_GivenDesktopMissingProjectPath_ThenSkipsToNextTargetWithPath(t *testing.T) {
+ ctx, mObj, mPath, mDev, svc := setupWorkspaceTest(t)
+ SetOnlineChecker(fakeOnlineChecker{online: map[string]bool{"fp-desk": true, "fp-agentred": true}})
+
+ mObj.EXPECT().ListByKinds(ctx, int64(7), gomock.Any()).Return([]*sync_entity.SyncObject{
+ {Kind: sync_entity.KindAgent, SyncID: "agent-1", Payload: mustJSON(t, map[string]any{"name": "后端 Agent"})},
+ {Kind: sync_entity.KindProject, SyncID: "proj-1", Payload: mustJSON(t, map[string]any{"name": "agentre-server"})},
+ {Kind: sync_entity.KindAgentBackend, SyncID: "b-desk", AgentredFingerprint: "fp-desk",
+ Payload: mustJSON(t, map[string]any{"type": "claude_code"})},
+ {Kind: sync_entity.KindAgentBackend, SyncID: "b-agentred", AgentredFingerprint: "fp-agentred",
+ Payload: mustJSON(t, map[string]any{"type": "codex"})},
+ {Kind: sync_entity.KindAgentExecTarget, SyncID: "t1",
+ Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-desk", "sort_order": 0})},
+ {Kind: sync_entity.KindAgentExecTarget, SyncID: "t2",
+ Payload: mustJSON(t, map[string]any{"agent_sync_id": "agent-1", "backend_sync_id": "b-agentred", "sort_order": 1})},
+ {Kind: sync_entity.KindProjectLocation, SyncID: "loc-1", ProjectSyncID: "proj-1",
+ AgentredFingerprint: "fp-agentred", Payload: mustJSON(t, map[string]any{"path": "/srv/agentre-server"})},
+ // 桌面端上报组里没有 proj-1 的路径(它的 ListByDevice 返回空)。
+ }, nil)
+ mDev.EXPECT().ListByUser(ctx, int64(7)).Return([]*device_entity.Device{
+ {ID: 30, UserID: 7, Name: "家里 Mac mini", Kind: device_entity.KindDesktop, Fingerprint: "fp-desk", Status: 1},
+ {ID: 21, UserID: 7, Name: "公司 Mac mini", Kind: device_entity.KindAgentred, Fingerprint: "fp-agentred", Status: 1},
+ }, nil)
+ mPath.EXPECT().ListByDevice(ctx, int64(7), int64(30)).Return(nil, nil)
+
+ plan, err := svc.WebDispatchPlan(ctx, 7, "agent-1", "proj-1")
+ require.NoError(t, err)
+ require.Len(t, plan.Tiers, 2)
+
+ assert.Equal(t, AvailabilityProjectPathMissing, plan.Tiers[0].Availability)
+ assert.Equal(t, "家里 Mac mini", plan.Tiers[0].DeviceName)
+ assert.False(t, plan.Tiers[0].Current)
+
+ assert.Equal(t, AvailabilityAvailable, plan.Tiers[1].Availability)
+ assert.True(t, plan.Tiers[1].Current)
+ assert.Equal(t, "公司 Mac mini", plan.Tiers[1].DeviceName)
+ assert.Equal(t, device_entity.KindAgentred, plan.Tiers[1].Kind)
+
+ require.NotNil(t, plan.Chosen)
+ assert.Equal(t, "fp-agentred", plan.Chosen.DeviceFingerprint)
+ assert.Equal(t, device_entity.KindAgentred, plan.Chosen.Kind)
+ assert.Equal(t, "/srv/agentre-server", plan.Chosen.Cwd)
+}