(PICKED_DIRECTORY)
@@ -85,6 +117,38 @@ const projects = {
},
} as unknown as AlphaProjectsApi
+/** #925:展开侧栏里某个项目(生产状态函数;会话行只在展开后渲染)。 */
+export function expandProject(worktree: string) {
+ setProjectExpanded(worktree, true)
+}
+
+/** #925:打开自动化面板(生产模块信号;面板本体与「回跳会话」按钮都是生产组件)。 */
+export function openAutomationPanel() {
+ setAutomationOpen(true)
+}
+
+/** #925:自动化任务夹具 —— 一条已完成的 run,带会话 id(「回跳会话」按钮的渲染条件)。 */
+const automationTaskFixture = {
+ id: "auto-1",
+ name: "夜间总结",
+ nlText: "每天 21 点总结当天工作",
+ schedule: { kind: "cron", expr: "0 21 * * *" },
+ target: { projectDir: FIXTURE_DIRECTORY, agent: "alpha-automation" },
+ prompt: "总结当天工作",
+ execution: "local",
+ permissionProfile: "readonly",
+ budget: { maxDurationMin: 30 },
+ overlapPolicy: "skip",
+ catchUpPolicy: "skip",
+ notify: { system: true },
+ enabled: true,
+ createdAt: "2026-08-10T21:00:00.000Z",
+ lastRun: { at: "2026-08-10T21:00:00.000Z", status: "ok", sessionID: AUTOMATION_SESSION_ID, summary: "已完成" },
+ history: [{ at: "2026-08-10T21:00:00.000Z", status: "ok", sessionID: AUTOMATION_SESSION_ID, summary: "已完成" }],
+ nextFireAt: null,
+ running: false,
+}
+
function RouteProbe() {
const location = useLocation()
// 导航请求必须在这里取,不能只看 location:上游会对 legacy 会话/草稿路由立刻再 redirect 一次,
@@ -170,6 +234,25 @@ const platform: Platform = {
/** 叶不在本闸门射程内:给最小标记件,免得把三棵叶子树一起拖进来。 */
const leaf = (name: string) => () =>
+/** #925:新对话叶的最小探针。`promoteDraft` 这个 prop 是**上游生产 wrapper**
+ * (packages/app/src/app.tsx createDraftRoute)交下来的 —— 点这个按钮走的是真实
+ * tabs.promoteDraft:真实 tab 交换 + 真实导航(tabHref 按 tab.server 拼 canonical 路由)。
+ * 叶只发起,判据落在真实 router 收到的落点上。 */
+const NewSessionPromoteProbe = (props: {
+ draftId: string
+ promoteDraft: (session: { directory: string; sessionId: string }) => void
+}) => (
+
+
+
+)
+
/**
* 设置面的**权威读**必须真的落定:设置页里那张快捷键表也是一批「指向命令」的入口(用户能改、
* 能存),而上游只对**已注册**的命令应用自定义键位 —— 表里留一条已退休的 id,就是又一个
@@ -200,7 +283,12 @@ export function installPreloadStub() {
start: async () => {},
logout: async () => {},
},
- automations: { list: async () => ({ tasks: [] }), onEvent: noop },
+ automations: {
+ // #925:完整形状(list 消费 r.state.pausedAll / r.loginItem);一条带 run 历史的任务,
+ // 「回跳会话」用例点的就是它。
+ list: async () => ({ tasks: [automationTaskFixture], state: { pausedAll: false }, loginItem: false }),
+ onEvent: noop,
+ },
account: { summary: async () => null },
contracts: { health: async () => null, subscribe: noop },
endpoints: async () => undefined,
@@ -272,29 +360,48 @@ export function resetHarness() {
setComposerPerm("ask")
setRegisteredCommands([])
setDraftTabs([])
+ // #925:自动化面板的开合是模块级信号,跨用例残留。
+ setAutomationOpen(false)
triggerCommand = undefined
}
-function Shell(props: { sidebar?: boolean; legacySettingsEntry?: boolean }) {
+function Shell(props: {
+ sidebar?: boolean
+ legacySettingsEntry?: boolean
+ /** #925:壳里挂的全部连接;缺省 = 单 sidecar(既有用例的世界)。 */
+ servers?: ServerConnection.Any[]
+ /** #925:active(default)server;缺省 sidecar。 */
+ activeServer?: ServerConnection.Any
+ /** #925:`projects` 这份 store「连着」的 server —— 生产里 `projectsServerKey` 反查出来的那台。 */
+ projectsServer?: ServerConnection.Any
+}) {
+ const servers = props.servers ?? [connection]
+ const active = props.activeServer ?? connection
+ const projectsServer = props.projectsServer ?? connection
+ // 与生产同构:serverKey 是「由连接算 key」的 accessor(renderer/index.tsx 的 projectsServerKey);
+ // 判据侧的锚点用独立字面量,不读这里(否则是自指等价链)。
+ const projectsServerKey = () => ServerConnection.key(projectsServer)
return (
- {props.sidebar === false ? null : }
+ {props.sidebar === false ? null : }
{/* 与 renderer/index.tsx 的 settings surface 同一接法。 */}
setSettingsOpen(false)} api={settingsApi} />
{/* `command.palette` 的唯一注册点(#659)。壳组成必须与 renderer/index.tsx 一致 ——
设置页快捷键表里就有这一条,少挂它会把「产品少注册了一条」误判成真。
负对照那个壳同样不挂它(它复现的正是「alpha 壳级注册还不存在」的世界)。 */}
- {props.sidebar === false ? null : SERVER_KEY} />}
+ {props.sidebar === false ? null : }
+ {/* #925:自动化面板与生产同构地挂在壳上(renderer/index.tsx),「回跳会话」用例点它。 */}
+ {props.sidebar === false ? null : }
{props.legacySettingsEntry ? : null}
@@ -317,6 +424,25 @@ export function AlphaShell() {
return
}
+/* ── #925:多 server 壳 ─────────────────────────────────────────────────────────
+ 三个变体的 {active, projects} 对各不相同,且 projects key 三者互异("sidecar" /
+ "wsl:fedora" / "wsl:arch")—— 判据锚在各自的独立字面量上,写死任何单值的实现无法全绿。 */
+
+/** active = wsl:ubuntu,projects store 连的是本地 sidecar("sidecar")。 */
+export function AlphaShellRemoteActive() {
+ return
+}
+
+/** active = sidecar,projects store 连的是 wsl:fedora。 */
+export function AlphaShellRemoteProjects() {
+ return
+}
+
+/** active = wsl:ubuntu,projects store 连的是 wsl:arch(自动化「回跳会话」用例)。 */
+export function AlphaShellAutomationRemote() {
+ return
+}
+
/**
* 负对照 = **本票之前的那个世界**:真实壳、首页路由,但不挂 alpha 侧栏(于是没有 alpha 的壳级
* 注册),只放一个按旧接线发 `settings.open` 的入口。上游那三处注册全在已被 alpha 顶替的叶里,
diff --git a/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts b/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts
index b7fd70b21994..629ea4b6ccea 100644
--- a/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts
+++ b/packages/ui-mac/src/renderer/sidebar/shell-commands.test.ts
@@ -24,7 +24,8 @@ import { build } from "vite"
import type { render } from "solid-js/web"
import type * as Runtime from "./shell-commands-test-runtime"
import { RETIRED_MENU_COMMANDS, publishedMenuCommands } from "../../shared/desktop-menu-policy"
-import { homeHref, newSessionHref, sessionHref } from "./route"
+import { homeHref, newSessionHref } from "./route"
+import { parseRoute } from "../../shared/route-manifest"
type TestRuntime = typeof Runtime & { render: typeof render }
@@ -127,6 +128,20 @@ async function openAccountMenu() {
await flush()
}
+/** #925:真实 router 收到的导航请求里,凡**解析成会话路由**的,抽出落点三元组。编码(生产
+ * `hrefFor`)与解码(生产 `parseRoute`)是两条相反的路,不构成自指等价链;判据锚在独立的
+ * server key 字面量上。 */
+function sessionLandings(intents: string[]) {
+ return intents
+ .map((href) => parseRoute(href))
+ .filter((route) => route.kind === "session")
+ .map((route) => ({
+ routeId: route.identity.routeId,
+ serverKey: (route as { serverKey?: string }).serverKey,
+ id: (route as { id?: string }).id,
+ }))
+}
+
describe("侧栏账户菜单「设置」:改接 alpha 自有设置面,与路由无关", () => {
test("在首页点设置 → 真实的 alpha 设置面出现在 DOM 里", async () => {
// 首页是「原来死得最彻底」的那条路由:上游三叶已被顶替、legacy layout 不挂载。
@@ -218,9 +233,14 @@ describe("空项目态「打开项目」:改走 alpha 自己的目录选择", ()
expect(runtime.pickerCalls()).toBe(1)
expect(runtime.createdIn()).toEqual([runtime.PICKED_DIRECTORY])
// 真实 router 真的收到了去那条会话路由的导航。判据取 router 自己的 beforeLeave,而不是
- // 最终 location:上游对 legacy 会话路由会立刻再 redirect 一次,中间那一站在同一批次里就被
- // 覆盖掉了(这条 admission 跳转正是 REQ-126 CODE-C 在处置的东西,不是本票的命题)。
- expect(runtime.navigationIntents()).toContain(sessionHref(runtime.PICKED_DIRECTORY, runtime.CREATED_SESSION_ID))
+ // 最终 location。#925 起落点是 canonical 的 `/server/:serverKey/session/:id`(单 server 壳,
+ // projects key = "sidecar");legacy 形状(壳按 active 反推的那种)一条都不许有 ——
+ // 多 server 下的判别在下方 #925 一节(那里各用例的 key 互不相同)。
+ const landed = sessionLandings(runtime.navigationIntents()).find((route) => route.id === runtime.CREATED_SESSION_ID)
+ expect(landed).toBeDefined()
+ expect(landed!.routeId).toBe("session")
+ expect(landed!.serverKey).toBe("sidecar")
+ expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([])
})
test("用户取消选择 → 不建会话、不跳转", async () => {
@@ -340,6 +360,10 @@ describe("桌面菜单:发布面上的每一条在真实壳里都接得住", ()
expect(runtime.navigationIntents()).not.toContain(newSessionHref(runtime.FIXTURE_DIRECTORY))
})
+ // #925 注:上面 session.new 那条断言 `added[0].server === "sidecar"` 在单 server 壳里
+ // active 与 projects key 同值,判别不了「draft 的 server 段来自谁」;多 server 的判别在
+ // 下方 #925 一节的 draft 晋升用例(active=sidecar、projects=wsl:fedora,两值相异)。
+
// common.goBack / common.goForward 已从桌面菜单**退休**(shared/desktop-menu-policy.ts),
// 所以这里没有它们的用例:上游 Titlebar 在首页/新对话页抢先注册同名 id 并走只有 `["/"]` 的私有
// history —— 那不是「语义相同、谁赢都行」,是同一菜单项在不同路由两种行为、其中一种还是空转。
@@ -347,3 +371,118 @@ describe("桌面菜单:发布面上的每一条在真实壳里都接得住", ()
// 行为用例:只在退休那两条用例里断言过左上工具簇仍有 3 个按钮,从未点击并观察导航。这是刻意的
// 诚实分层,已在 docs/architecture/upstream-integration.md 的「Known not covered」列明。
})
+
+/* ── #925:legacy sessionHref 的全部生产者清零 —— 会话导航钉在真正持有会话的 server 上 ────
+ 缺陷同形于 #894:legacy `/{目录}/session/{id}` 路径里没有 server 段,壳只能事后反推
+ (`packages/app/src/utils/session-route.ts` 的 `legacySessionServer`:同 id 的 tab,否则回落
+ 「完成时的 active server」)。多 server(WSL/remote)下反推恒给错机器;那台机器上若恰好有
+ 同 id 会话,打开并污染的是那个无关会话。修法是删掉产生器(sidebar/route.ts 的
+ `sessionHref`)并让四个消费者(侧栏点击 / 侧栏锚点 / 新会话导航 / 自动化回跳)+ draft 的
+ server 段全部消费 projects store 的 server 身份。
+
+ 判据纪律:
+ · 落点判据 = 真实 router 收到的 href 经生产 `parseRoute` 解回来的 {routeId, serverKey, id}
+ (编码与解码两条相反的路);锚点是**本文件的独立字面量**,不 import 生产常量。
+ · 各用例点击那一刻的 projects key 互不相同("sidecar" / "wsl:fedora" / "wsl:arch"),且都与
+ 该壳的 active server 相异 —— 写死任何单值、或按 active 反推的实现,至少两条当场红。
+ · 每条都扫一遍「legacy 形状的会话导航 = 0」:那是反推入口本身。 */
+describe("#925 多 server 下的会话导航:落在真正持有该会话的 server 上", () => {
+ test("点侧栏里没开过 tab 的会话(active=wsl:ubuntu,store 连 sidecar)→ 锚点与真实落点都钉在 sidecar", async () => {
+ await mountShell(() => runtime.AlphaShellRemoteActive())
+ runtime.expandProject(runtime.FIXTURE_DIRECTORY)
+ await settle()
+
+ // ① 锚点本身(右键复制/中键打开拿到的就是它):canonical 会话路由,server 段 = sidecar。
+ const anchor = document.querySelector("a.alpha-session")
+ expect(anchor).not.toBeNull()
+ const anchorLanding = parseRoute(anchor!.getAttribute("href")!)
+ expect({ kind: anchorLanding.kind, routeId: anchorLanding.identity.routeId }).toEqual({
+ kind: "session",
+ routeId: "session",
+ })
+ expect((anchorLanding as { serverKey?: string }).serverKey).toBe("sidecar")
+
+ // ② 真点击:真实 router 收到的落点同样钉在 sidecar,而不是 active 的 wsl:ubuntu。
+ const before = runtime.navigationIntents().length
+ anchor!.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
+ await settle()
+ const landed = sessionLandings(runtime.navigationIntents().slice(before)).find((route) => route.id === "ses_one")
+ expect(landed).toBeDefined()
+ expect(landed!.routeId).toBe("session")
+ expect(landed!.serverKey).toBe("sidecar")
+ expect(landed!.serverKey).not.toBe("wsl:ubuntu")
+
+ // ③ 反推入口清零:全程一条 legacy 形状的会话导航都没有。
+ expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([])
+ })
+
+ test("「打开项目」开出的新会话(active=sidecar,store 连 wsl:fedora)→ 落点钉在 wsl:fedora", async () => {
+ runtime.setHasProjects(false)
+ await mountShell(() => runtime.AlphaShellRemoteProjects())
+
+ openProjectButton()!.click()
+ await settle()
+
+ expect(runtime.createdIn()).toEqual([runtime.PICKED_DIRECTORY])
+ const landed = sessionLandings(runtime.navigationIntents()).find((route) => route.id === runtime.CREATED_SESSION_ID)
+ expect(landed).toBeDefined()
+ expect(landed!.routeId).toBe("session")
+ // 与上一条用例点击那一刻的 key("sidecar")相异 —— 写死单值的实现两条不可能同时绿。
+ expect(landed!.serverKey).toBe("wsl:fedora")
+ expect(landed!.serverKey).not.toBe("sidecar")
+ expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([])
+ })
+
+ test("draft 晋升(active=sidecar,store 连 wsl:fedora):draft 带 store 的 server,上游 promoteDraft 的真实导航落在 wsl:fedora", async () => {
+ await mountShell(() => runtime.AlphaShellRemoteProjects())
+
+ // 启动导航自己建 draft(#656)。#925:draft 的 server 段 = store 的 server,不再是 active ——
+ // 会话是 `projects.startChat` 在 store 的 server 上建的,draft 带 active 时,上游
+ // `promoteDraft`(packages/app createDraftRoute)会把 session tab 与导航都落到一台没有
+ // 这个会话的机器上。
+ await waitFor(() => runtime.draftTabs().length > 0, "启动草稿落地")
+ const draft = runtime.draftTabs().at(-1)!
+ expect(draft.server).toBe("wsl:fedora")
+ expect(draft.server).not.toBe("sidecar")
+ await waitFor(() => runtime.routerPath() === runtime.draftHref(draft.draftID), "壳落在新草稿页")
+
+ // 探针把「已建成的会话」交回**上游生产 promoteDraft**:真实 tabs 交换 + 真实导航。
+ const probe = document.querySelector("[data-harness-promote]")
+ expect(probe).not.toBeNull()
+ probe!.click()
+ await waitFor(
+ () => sessionLandings(runtime.navigationIntents()).some((route) => route.id === runtime.PROMOTED_SESSION_ID),
+ "晋升导航落地",
+ )
+ const landed = sessionLandings(runtime.navigationIntents()).find((route) => route.id === runtime.PROMOTED_SESSION_ID)
+ expect(landed!.routeId).toBe("session")
+ expect(landed!.serverKey).toBe("wsl:fedora")
+ expect(landed!.serverKey).not.toBe("sidecar")
+ expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([])
+ })
+
+ test("自动化「回跳会话」(active=wsl:ubuntu,store 连 wsl:arch)→ 落点钉在 wsl:arch", async () => {
+ await mountShell(() => runtime.AlphaShellAutomationRemote())
+ runtime.openAutomationPanel()
+ await waitFor(() => document.querySelector(".alpha-auto-row") !== null, "自动化任务列表渲染")
+
+ // 进任务详情 → 运行历史 → 点「打开会话」(带 sessionID 的那条 run 才渲染这个按钮)。
+ document
+ .querySelector(".alpha-auto-row")!
+ .dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }))
+ await waitFor(() => document.querySelector(".alpha-auto-hist .alpha-ext-link") !== null, "运行历史的回跳按钮渲染")
+ document.querySelector(".alpha-auto-hist .alpha-ext-link")!.click()
+ await settle()
+
+ const landed = sessionLandings(runtime.navigationIntents()).find(
+ (route) => route.id === runtime.AUTOMATION_SESSION_ID,
+ )
+ expect(landed).toBeDefined()
+ expect(landed!.routeId).toBe("session")
+ // run 的会话由主进程建在 projects store 连着的那台上;三个 #925 用例的 key 各不相同。
+ expect(landed!.serverKey).toBe("wsl:arch")
+ expect(landed!.serverKey).not.toBe("wsl:ubuntu")
+ expect(landed!.serverKey).not.toBe("sidecar")
+ expect(sessionLandings(runtime.navigationIntents()).filter((route) => route.routeId === "legacy-session")).toEqual([])
+ })
+})
diff --git a/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx b/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx
index bbcce95f62f4..72a338ef8b61 100644
--- a/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx
+++ b/packages/ui-mac/src/renderer/sidebar/start-draft-test-runtime.tsx
@@ -392,7 +392,9 @@ export function AlphaShell(props: { connection?: ServerConnection.Any }) {
}}
>
-
+ {/* #925:本 harness 是单连接世界 —— `projects` 就连着这一个连接(见上方 #891 注),
+ 所以侧栏的 serverKey 与它同源;draft 的 server 段自此取自这里,不再读 active。 */}
+ ServerConnection.key(connection)} />
diff --git a/packages/ui-mac/src/shared/route-manifest.test.ts b/packages/ui-mac/src/shared/route-manifest.test.ts
index 5e266c881ea1..46237b78853e 100644
--- a/packages/ui-mac/src/shared/route-manifest.test.ts
+++ b/packages/ui-mac/src/shared/route-manifest.test.ts
@@ -277,9 +277,11 @@ describe("manifest-derived parse, href, and navigation", () => {
expect(hrefFor.sessionAdmission("/Users/dev/proj", "continue here")).toBe(
"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/L1VzZXJzL2Rldi9wcm9q/session?prompt=continue%20here",
)
- expect(hrefFor.legacySession("/Users/dev/proj", "ses_123")).toBe(
- "/L1VzZXJzL2Rldi9wcm9q/session/ses_123",
- )
+ // `#925`:`hrefFor` 里**故意没有** legacySession —— legacy 形状不带 server 段,是 `#894`/`#925`
+ // 那个「落到没有该会话的机器」缺陷的产生器。这里正向钉住「它不在」,否则谁顺手加回来无人变红。
+ expect("legacySession" in hrefFor).toBe(false)
+ // 形状本身仍要能造出来(`parseRoute` 要解析 packages/app 今天仍在产的那批 URL),只是不在 hrefFor 里。
+ expect(navFor.legacySession("/Users/dev/proj", "ses_123").href).toBe("/L1VzZXJzL2Rldi9wcm9q/session/ses_123")
expect(hrefFor.session("sidecar", "ses_123")).toBe("/server/c2lkZWNhcg/session/ses_123")
expect(hrefFor.newSession("d 1", "a+b")).toBe("/new-session?draftId=d%201&prompt=a%2Bb")
expect(hrefFor.newSession("d1")).toBe("/new-session?draftId=d1")
diff --git a/packages/ui-mac/src/shared/route-manifest.ts b/packages/ui-mac/src/shared/route-manifest.ts
index 560fd0a68e61..51384e444b92 100644
--- a/packages/ui-mac/src/shared/route-manifest.ts
+++ b/packages/ui-mac/src/shared/route-manifest.ts
@@ -555,7 +555,14 @@ export const hrefFor = {
directory: (directory: string) => requireHref(navFor.directory(directory)),
sessionAdmission: (directory: string, prompt?: string) => requireHref(navFor.sessionAdmission(directory, prompt)),
directorySession: (directory: string, prompt?: string) => requireHref(navFor.sessionAdmission(directory, prompt)),
- legacySession: (directory: string, sessionId: string) => requireHref(navFor.legacySession(directory, sessionId)),
+ // `#925`:**故意没有 `legacySession`**。legacy 形状(`/{目录}/session/{id}`)不带 server 段,
+ // 壳只能事后按「完成时的 active server」或「同 id 的 tab」反推 —— 多 server 下那就是落到没有该
+ // 会话的机器,对面若恰好有同 id 会话,打开并污染的是那个无关会话(`#894` / `#925` 的用户可观察缺陷)。
+ // ui-mac 生产侧已零消费,故把这个**顺手就能拿到的**产生器从这里撤掉:新写的调用点要造出 legacy
+ // 形状,必须绕到 `navFor.legacySession(...).href` 去 —— 那是一个需要解释的动作,不再是默认路径。
+ // 诚实边界:这不是完整的咽喉 —— `navFor.legacySession` 仍在(`parseRoute` 要解析 `packages/app`
+ // 今天仍在产的那批 legacy URL:notification.tsx / dialog-fork.tsx / prompt-input/submit.ts)。
+ // 真正的默认拒绝要等那批生产者按 ADR-034 的补丁序列迁完,见票面 openRisk ①。
session: (serverKey: string, sessionId: string) => requireHref(navFor.session(serverKey, sessionId)),
newSession: (draftId: string, prompt?: string) => requireHref(navFor.newSession(draftId, prompt)),
} as const
diff --git a/packages/ui-mac/test-component/new-session-workspace.cases.ts b/packages/ui-mac/test-component/new-session-workspace.cases.ts
index 606255717e91..e1359e623dae 100644
--- a/packages/ui-mac/test-component/new-session-workspace.cases.ts
+++ b/packages/ui-mac/test-component/new-session-workspace.cases.ts
@@ -49,10 +49,13 @@ Bun.plugin({
AlphaNewSession reads the draft through upstream `useTabs()` and clears `?prompt=`
through the router; both are replaced with observable fakes so the assertions can be
about *what the page asked for*, not about internal calls. */
-// #891:两个「当会话身份用就是错的」的值,在本文件里被**故意钉成与 store 的 server 不同**:
-// `useServer().key`(当前 active server)与 `tabs.draft().server`(建 draft 那一刻的 active
-// server)。首页/新对话页的会话是 `projects.startChat` 在 **store 连着的那个 server** 上建的,
-// 身份只能是后者。两个错值都取 ACTIVE_SERVER_KEY,任一叶回头去读它们,判据当场红。
+// #891:「当会话身份用就是错的」的值在本文件里被**故意钉成与 store 的 server 不同**:
+// `useServer().key`(当前 active server)恒为 ACTIVE_SERVER_KEY,任一叶回头去读它,判据当场红。
+// 首页/新对话页的会话是 `projects.startChat` 在 **store 连着的那个 server** 上建的,身份只能是
+// 后者。draft 的 server 段:#925 起 alpha-sidebar 建的 draft 已钉在 projects key 上,且
+// AlphaNewSession 在唯一消费者处把不同值的 draft 收口钉回(#925 审计 Major,上游 Titlebar 的
+// 「+ 新标签」/ mod+t 仍按 active server 建 draft)—— 所以本文件默认 draft 带 STORE_SERVER_KEY
+// (生产两头收口后的稳态),「上游入口建的 wrong-server draft」由 #925 那组用例显式铺设。
const ACTIVE_SERVER_KEY = "wsl:ubuntu"
/** `projects` 这份 store 真正连着的 server(生产由 `index.tsx` 的 `projectsServerKey` 反查)。 */
const STORE_SERVER_KEY = "sidecar"
@@ -66,7 +69,7 @@ const LATER_SERVER_KEY = "wsl:debian"
const SUBMIT_TIME_SERVER_KEY = "wsl:fedora"
type DraftRecord = { server: string; directory: string }
-const [draft, setDraft] = createSignal({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" })
+const [draft, setDraft] = createSignal({ server: STORE_SERVER_KEY, directory: "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/ws/a" })
// 上游 tabs.store 的最小形状:哪些 draft 还活着(关掉 = 从这里消失)。
const [liveDrafts, setLiveDrafts] = createSignal(["draft-1"])
const updateDraftCalls: Array<{ draftID: string; patch: Partial }> = []
@@ -87,7 +90,13 @@ const server = {
return ACTIVE_SERVER_KEY
},
}
-mock.module("@opencode-ai/app", () => ({ useTabs: () => tabs, useServer: () => server }))
+mock.module("@opencode-ai/app", () => ({
+ useTabs: () => tabs,
+ useServer: () => server,
+ // #925:生产的 `ServerConnection.Key.make` 就是 brand cast(packages/app/src/context/server.tsx:238),
+ // 这里如实照抄 —— 收口 effect 写回 draft 的值不经任何变换。
+ ServerConnection: { Key: { make: (v: string) => v } },
+}))
/** #894:叶真正跳去了哪里。路由本体不参与 —— 记的是叶交给 `useNavigate()` 的那个 href,
* 再由生产的 `parseRoute` 读回去,编码与解码是两条相反的路,不构成自指等价链。 */
const navigateCalls: string[] = []
@@ -335,8 +344,12 @@ function pasteFile(el: HTMLTextAreaElement, file: File) {
el.dispatchEvent(event)
}
-function DraftLeaf(props: { projects: AlphaProjectsApi; draftId?: string }) {
- // 上游 createDraftRoute 的 keyed 包装(packages/app/src/app.tsx):directory 变 ⇒ 整叶重挂。
+/** #925:上游 `createDraftRoute` 的 `promoteDraft` 按 **`props.draft.server` 晋升那一刻的值**建
+ * session tab 并导航(packages/app/src/app.tsx:272-277)—— 这里如实照抄那次读取并记下来。
+ * 它就是「打开的是哪台机器」的决定值:断言落在它身上,而不是某个内部 memo。 */
+const promoteCalls: Array<{ server: string; sessionId: string }> = []
+function DraftLeaf(props: { projects: AlphaProjectsApi; draftId?: string; serverKey?: () => string | undefined }) {
+ // 上游 createDraftRoute 的 keyed 包装(packages/app/src/app.tsx):server/directory 变 ⇒ 整叶重挂。
return createComponent(solid.Show, {
get when() {
return `${draft().server}\0${draft().directory}`
@@ -346,10 +359,13 @@ function DraftLeaf(props: { projects: AlphaProjectsApi; draftId?: string }) {
return createComponent(AlphaNewSession, {
projects: props.projects,
// #891:生产由 index.tsx 的 `projectsServerKey` 供给(store 的 baseUrl 反查),这里同源:
- // 这份 `projects` 连的就是 STORE_SERVER_KEY,而 `draft().server` 是另一个值。
- serverKey: () => STORE_SERVER_KEY,
+ // 这份 `projects` 连的就是 STORE_SERVER_KEY(#925 的用例用自己的 key 覆盖,两组字面量
+ // 互不相同 —— 写死单值的实现过不了)。
+ serverKey: props.serverKey ?? (() => STORE_SERVER_KEY),
draftId: props.draftId ?? "draft-1",
- promoteDraft: () => {},
+ promoteDraft: (session: { sessionId: string }) => {
+ promoteCalls.push({ server: draft().server, sessionId: session.sessionId })
+ },
})
},
})
@@ -367,7 +383,8 @@ beforeEach(() => {
pickedDirectory = undefined
defaultWorkspaceGate = undefined
setLiveDrafts(["draft-1"])
- setDraft({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" })
+ setDraft({ server: STORE_SERVER_KEY, directory: "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/ws/a" })
+ promoteCalls.splice(0)
document.body.replaceChildren()
})
@@ -415,7 +432,7 @@ describe("REQ-126 CODE-D 新对话页工作区选择器", () => {
})
test("未显式选择(draft 无目录)⇒ chip 与标题都显示默认对话目录 ~/Alpha", async () => {
- setDraft({ server: ACTIVE_SERVER_KEY, directory: "" })
+ setDraft({ server: STORE_SERVER_KEY, directory: "" })
const host = mount(() => createComponent(DraftLeaf, { projects: projectsApi([project("alpha-code", "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/ws/a")]) }))
await flush()
await flush()
@@ -932,8 +949,12 @@ describe("#891 首页/新对话页:开局档位登记在 store 连着的那个 s
test("新对话页同一条线:draft.server 不是身份,登记仍在 store 的 server 名下", async () => {
const projects = projectsApi([project("alpha-code", "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/ws/a")])
+ // draft 生来带 active server(上游入口的形态)。#925 收口会先钉回 projects key 并整叶重挂
+ // 一次 —— 等它落定再开档位:开在旧实例上的档随重挂消失,那是收口的已知代价(内容经暂存
+ // 保住,档位是 composer 实例状态),不是本用例的命题。
setDraft({ server: ACTIVE_SERVER_KEY, directory: DEFAULT_WORKSPACE })
const leaf = mountDisposable(() => createComponent(DraftLeaf, { projects }))
+ for (let i = 0; i < 20 && draft().server !== STORE_SERVER_KEY; i++) await flush()
for (let i = 0; i < 20 && !textarea(leaf.host); i++) await flush()
textarea(leaf.host)!.dispatchEvent(
@@ -1120,3 +1141,75 @@ describe("#894 首页提交后的导航:落地的是真正创建会话的那个
home.dispose()
})
})
+
+/* ── #925 审计 Major:draft 的生产者不止 alpha-sidebar 一个 ────────────────────────────
+ 上游 Titlebar 的「+ 新标签」/ mod+t(packages/app/src/components/titlebar.tsx `openNewTab`,
+ 五个分支)建的 draft 带的是 active(或兜底)server;而新对话页发第一条恒经
+ `props.projects.startChat` 把会话建在 projects store 连着的 server 上。收口在唯一消费者:
+ AlphaNewSession 挂载/取到 key 后把不同值的 draft.server 钉回 projects key,上游 `promoteDraft`
+ 按 `draft.server` 建的 session tab 与导航才落在真正持有会话的那台。
+ 判据纪律:两组用例的 projects key 用**不同**字面量(STORE / LATER),写死单值的实现两条同时
+ 过不了;负向夹具的 draft.server 也取两个互不相同的非退化值(ACTIVE / SUBMIT_TIME)。 */
+describe("#925 上游入口建的 draft(带 active server)在唯一消费者处被钉回 projects key", () => {
+ test("draft 带 active server ⇒ server 段被钉回,晋升读到的就是 projects key;收口重挂不吞已有内容", async () => {
+ // 用户此前在这条 draft 里输入过(经暂存注入 —— 与切目录内容保护同一条路)。
+ newSessionDraftStash.capture("draft-1", { text: "上游入口建的草稿", mentions: [], attachments: [], pendingReads: [] })
+ setDraft({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" })
+ const host = mount(() => createComponent(DraftLeaf, { projects: projectsApi([project("alpha-code", "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/ws/a")]) }))
+ for (let i = 0; i < 20 && draft().server !== STORE_SERVER_KEY; i++) await flush()
+
+ expect(updateDraftCalls).toEqual([{ draftID: "draft-1", patch: { server: STORE_SERVER_KEY } }])
+ expect(draft().server).toBe(STORE_SERVER_KEY)
+ // 收口引发的重挂不吞内容(卸载捕获 → 重挂取回,与切目录同一条暂存路)。
+ for (let i = 0; i < 20 && !textarea(host)?.value; i++) await flush()
+ expect(textarea(host)!.value).toBe("上游入口建的草稿")
+
+ // 用户可观察的终点:晋升(上游按 draft.server 建 tab + 导航)读到的 server 是 projects key,
+ // 不是建 draft 那一刻的 active server —— 打开的才是真正持有这个会话的机器。
+ textarea(host)!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }))
+ for (let i = 0; i < 30 && promoteCalls.length === 0; i++) await flush()
+ expect(startChatCalls).toHaveLength(1)
+ expect(promoteCalls).toEqual([{ server: STORE_SERVER_KEY, sessionId: "session-1" }])
+ })
+
+ test("钉回的是**这份 projects 连着的那个** key,不是任何单一字面量(第二组 key 全不同)", async () => {
+ setDraft({ server: SUBMIT_TIME_SERVER_KEY, directory: "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/ws/a" })
+ mount(() =>
+ createComponent(DraftLeaf, {
+ projects: projectsApi([project("alpha-code", "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/ws/a")]),
+ serverKey: () => LATER_SERVER_KEY,
+ }),
+ )
+ for (let i = 0; i < 20 && draft().server !== LATER_SERVER_KEY; i++) await flush()
+ expect(updateDraftCalls).toEqual([{ draftID: "draft-1", patch: { server: LATER_SERVER_KEY } }])
+ expect(draft().server).toBe(LATER_SERVER_KEY)
+ })
+
+ test("draft.server 已等于 projects key ⇒ 零写入、零重挂(收口是条件的,不是每次挂载都拍一下)", async () => {
+ const host = mount(() => createComponent(DraftLeaf, { projects: projectsApi([project("alpha-code", "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/ws/a")]) }))
+ const before = textarea(host)!
+ for (let i = 0; i < 5; i++) await flush()
+ expect(updateDraftCalls).toEqual([])
+ expect(draft().server).toBe(STORE_SERVER_KEY)
+ expect(textarea(host)).toBe(before) // 没重挂
+ })
+
+ test("挂载时 key 还没就绪(sidecar 迟启动)⇒ 不猜不写;就绪那一刻补钉", async () => {
+ setDraft({ server: ACTIVE_SERVER_KEY, directory: "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/ws/a" })
+ const [lateKey, setLateKey] = createSignal(undefined)
+ mount(() =>
+ createComponent(DraftLeaf, {
+ projects: projectsApi([project("alpha-code", "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/ws/a")]),
+ serverKey: lateKey,
+ }),
+ )
+ for (let i = 0; i < 5; i++) await flush()
+ expect(updateDraftCalls).toEqual([]) // key 缺席:不写 undefined,也不拿 active 兜底
+ expect(draft().server).toBe(ACTIVE_SERVER_KEY)
+
+ setLateKey(STORE_SERVER_KEY)
+ for (let i = 0; i < 20 && draft().server !== STORE_SERVER_KEY; i++) await flush()
+ expect(updateDraftCalls).toEqual([{ draftID: "draft-1", patch: { server: STORE_SERVER_KEY } }])
+ expect(draft().server).toBe(STORE_SERVER_KEY)
+ })
+})
diff --git a/packages/ui-mac/test-component/overlay-close.cases.ts b/packages/ui-mac/test-component/overlay-close.cases.ts
index 603a05cd93bd..95a6239c8918 100644
--- a/packages/ui-mac/test-component/overlay-close.cases.ts
+++ b/packages/ui-mac/test-component/overlay-close.cases.ts
@@ -71,7 +71,9 @@ mock.module("../src/renderer/alpha-ui/providers", () => ({
useCommand: () => ({ options: [], register: () => {}, trigger: () => {}, show: () => {}, hide: () => {} }),
// 侧栏用 `ServerConnection.key` 派生内嵌 sidecar 的稳定 key(不硬编码字面量);这里给同结果的
// 最小替身,与下面 useServer 的 key 对齐,否则同源判定会把本闸门的 server 当成"别的 server"。
- ServerConnection: { key: () => "sidecar" },
+ // #925:startDraft 用 `ServerConnection.Key.make(props.serverKey())` 给 draft 钉 server 段;
+ // 替身与上游同义(Key.make 是 brand 构造,运行时即恒等)。
+ ServerConnection: { key: () => "sidecar", Key: { make: (value: string) => value } },
useTabs: () => ({
ready: Object.assign(() => true, { promise: undefined }),
newDraft: () => new Promise(() => {}),
@@ -124,7 +126,6 @@ function apiNode(path: string[]): unknown {
// —— 生产组合体(真实侧栏 + 真实两个覆盖层 + 真实路由)—————————————————————————
const runtime = await import("../src/renderer/sidebar/overlay-close-test-runtime")
-const { sessionHref } = await import("../src/renderer/sidebar/route")
const { hrefFor } = await import("../src/shared/route-manifest")
const extHubState = await import("../src/renderer/extensions/ext-hub-state")
const automationState = await import("../src/renderer/automations/automation-state")
@@ -237,8 +238,10 @@ async function open(overlay: (typeof OVERLAYS)[number]) {
await flush()
}
-const CURRENT_HREF = sessionHref(DIR, CURRENT_SESSION)
-const OTHER_HREF = sessionHref(DIR, OTHER_SESSION)
+// #925:侧栏会话锚点已是 canonical 的 `/server/:serverKey/session/:id`(legacy 产生器已删);
+// key 与 harness 传给侧栏的 serverKey 同值("sidecar")。
+const CURRENT_HREF = hrefFor.session("sidecar", CURRENT_SESSION)
+const OTHER_HREF = hrefFor.session("sidecar", OTHER_SESSION)
const DRAFT_A = hrefFor.newSession("draft-a")
const DRAFT_B = hrefFor.newSession("draft-b")
diff --git a/scripts/gate-files.tsv b/scripts/gate-files.tsv
index 1c4b76f6d915..b507d72cb608 100644
--- a/scripts/gate-files.tsv
+++ b/scripts/gate-files.tsv
@@ -108,7 +108,7 @@
2 packages/ui-mac src/renderer/alpha-ui/reduced-motion-ratchet.test.ts - reduced-motion 棘轮:新动效必须尊重降级偏好
10 packages/ui-mac src/renderer/alpha-ui/alpha-session-search.test.ts - REQ-126 CODE-F:真实壳里 command.palette 确实被注册且侧栏按钮真能触发,跳转 href 带结果来源 server 身份
12 packages/ui-mac src/renderer/sidebar/start-draft.test.ts - REQ-126 AC1/AC4/AC5:新对话与冷启动落地都不再经上游 legacy admission 路由。挂真实 AppInterface + 生产 AlphaSidebar,点真实「新对话」,以 MutationObserver + 每轮采样断言「出现 data-alpha-new-session 之前,上游左栏与 alpha 会话工作区一次都没挂过」;首条用例是控制组(直接进 //session 断言两者确实会出现),没有它后面全是空绿。另含同源 fail-closed(WSL / loopback http 隧道)与两处等待的 barrier 用例。下界 = 实际条数 12,不留余量:留余量则删一格仍能过
-11 packages/ui-mac src/renderer/sidebar/shell-commands.test.ts - REQ-126 AC7:真实壳(生产 AlphaSidebar+AlphaSettings+AlphaSessionSearch)里点**处置表所列的那些入口**,断言可观察结果(设置面 DOM / 目录选择被调 / 真实路由变化);退休入口断言 DOM 不存在且容器仍在;设置页快捷键表断言不含退休 id 且每条都真注册;桌面菜单发布面逐条要求真注册。未覆盖的见 docs/architecture/upstream-integration.md「Known not covered」。下界=实际条数
+15 packages/ui-mac src/renderer/sidebar/shell-commands.test.ts - REQ-126 AC7:真实壳(生产 AlphaSidebar+AlphaSettings+AlphaSessionSearch+AutomationPanel)里点**处置表所列的那些入口**,断言可观察结果(设置面 DOM / 目录选择被调 / 真实路由变化);退休入口断言 DOM 不存在且容器仍在;设置页快捷键表断言不含退休 id 且每条都真注册;桌面菜单发布面逐条要求真注册。#925 增四条:多 server 壳(active≠projects,三个用例的 projects key 互异)下侧栏点击/锚点/「打开项目」/draft 晋升(上游生产 promoteDraft 真跑)/自动化「回跳会话」全部落在真正持有会话的 server 上,且 legacy 形状的会话导航清零 —— 删掉它们,legacy 反推(按完成时 active server 猜)复活时无物变红。未覆盖的见 docs/architecture/upstream-integration.md「Known not covered」。下界=实际条数
1 packages/ui-mac src/main/desktop-menu-publication.test.ts - REQ-126 AC7:起子进程跑 test-component/desktop-menu-publication.cases.ts(mock electron 跑真 createMenu,把真原生菜单**整个**点一遍,发出的命令 id 集必须恰好等于发布面 shared/desktop-menu-policy)。本文件直接执行判据并钉住子进程用例条数下界 11,故 delegates_to = '-'
1 packages/ui-mac src/renderer/sidebar/overlay-close.test.ts - REQ-126 AC2 覆盖层随导航关闭:挂真实覆盖层宿主 × 真实侧栏点击的参数化矩阵(含「点当前正在看的那个会话」这条 URL 不变的路径)。判据在 test-component/overlay-close.cases.ts,由本文件起子进程**直接执行**并钉住条数下界 —— 不是委派,故 delegates_to = '-'
19 packages/ui-mac src/shared/platform-pricing-authority-ratchet.test.ts packages/ui-mac/src/renderer/alpha-ui/model-picker-core.test.ts,packages/ui-mac/src/renderer/alpha-ui/model-default-core.test.ts,packages/ui-mac/src/renderer/alpha-ui/alpha-composer-model.component.test.ts #679/ADR-039 平台计价权威**源码**棘轮 —— 抬头自陈主判据不在本文件(它只守住「被删掉的本地档位轴没在目录面重新长出来」),故三个行为级受托方必须在册。含扫描器自测:正则写错的棘轮会报零违例、看起来和通过一模一样。下界=实际条数 19,不留余量