feat(session,web): 会话角色不可变 — PATCH 拒 personaId + 移除前端换角色入口
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

design/22 §8-1(主人 2026-10-03 拍板):会话角色在创建时确定、之后不可变,
要换角色必须新开会话。

后端:
- PATCH /api/v1/sessions/{id} 的 personaId 改为 json.RawMessage 判「键出现」,
  出现即 400(null/""/未知 id 一律拒),且先于会话存在性检查;错误消息点名 personaId
- 删除 Server.setSessionPersona 与 PATCH 的 501 分支;title/pinned 行为逐字不变
- sessionPersonaWriter 接口与 history.Store.SetSessionPersona 保留(存储能力)

前端:
- 移除会话行「切换角色」入口与面板「挂角色」下拉(含 setSessionPersona action、
  lib/sessionPersona.ts);保留「新建会话选角色」;面板「角色」列改为只读展示

契约:Docs/api-client.md -> v0.46(破坏性:依赖 PATCH 换角色的客户端须改为新建会话)

验证:go build/vet ./... 绿;gateway+history 包测试绿(-count=1);前端 npm run build
(tsc --noEmit + vite build)绿;9 个前端逻辑测试全绿。
变异验证:改回「静默忽略」/「写绑定」两种反向实现,新断言均 FAIL。
This commit is contained in:
AskaEth committed 2026-10-03 21:24:51 +08:00
1 parent f9430dee81
commit c7ecc3a249
10 files changed
+193 -448

No files matched your search

+12 -123
View File
@@ -1,12 +1,12 @@
// Logic test for per-session role selection (design/22 §4.6/§7, batch 1 frontend):
// the role the UI renders is *derived* from the active session — never a global
// value — an unbound (empty/absent) `personaId` falls back to the default role, a
// re-bind is written locally first and rolled back when the PATCH is rejected, and
// the role-scoped `mood` frame lands in its own role's bucket. The project has no
// frontend test runner and no jsdom, so this is a dependency-free plain-node check
// that drives the real resolver (lib/persona.ts), the real re-bind helper
// (lib/sessionPersona.ts), the real WS frame router (lib/ws.ts) and the real
// store, and throws on the first failure.
// value — an unbound (empty/absent) `personaId` falls back to the default role, and
// the role-scoped `mood` frame lands in its own role's bucket. Nothing here re-binds
// a session: a role is fixed at creation (design/22 §8-1), so picking a different
// role means creating a session. The project has no frontend test runner and no
// jsdom, so this is a dependency-free plain-node check that drives the real resolver
// (lib/persona.ts), the real WS frame router (lib/ws.ts) and the real store, and
// throws on the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change); the banner supplies the `window.localStorage` the store's
@@ -23,7 +23,6 @@
// imports nothing from node:*.
import { resolveMessagePersona, resolveSessionPersona } from "../lib/persona";
import { setSessionPersona, type SessionPersonaIO } from "../lib/sessionPersona";
import { dispatchFrame } from "../lib/ws";
import { selectCurrentPersona } from "./personaSelectors";
import { useAppStore } from "./useAppStore";
@@ -96,117 +95,9 @@ function testFallbackToDefaultRole(): void {
same(resolveSessionPersona(MAIN, fixtures, noFlag)?.id, "yuki", "no isDefault flag falls back to the first role");
}
/** 3. A re-bind writes locally first, then persists; a rejection restores it. */
async function testOptimisticRebind(): Promise<void> {
const calls: string[] = [];
const state: Record<string, string> = { [SUB_A]: "yuki" };
const io: SessionPersonaIO = {
read: (id) => state[id] ?? "",
write: (id, personaId) => {
state[id] = personaId;
calls.push(`write ${id}=${personaId}`);
},
persist: (id, personaId) => {
calls.push(`persist ${id}=${personaId}`);
return Promise.resolve();
},
};
same(await setSessionPersona(io, SUB_A, "cyrene"), true, "an accepted re-bind resolves true");
same(state[SUB_A], "cyrene", "local binding after a successful PATCH");
same(
calls,
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`],
"the local write happens before the request (no request would mean no UI change)"
);
// Rejection: the local value must go back, or the UI would show a role the
// server never accepted until the next GET /sessions corrects it.
const failedCalls: string[] = [];
const failedState: Record<string, string> = { [SUB_A]: "yuki" };
const failing: SessionPersonaIO = {
read: (id) => failedState[id] ?? "",
write: (id, personaId) => {
failedState[id] = personaId;
failedCalls.push(`write ${id}=${personaId}`);
},
persist: (id, personaId) => {
failedCalls.push(`persist ${id}=${personaId}`);
return Promise.reject(new Error("patch failed"));
},
};
same(await setSessionPersona(failing, SUB_A, "cyrene"), false, "a rejected re-bind resolves false");
same(failedState[SUB_A], "yuki", "local binding restored after a failed PATCH");
same(
failedCalls,
[`write ${SUB_A}=cyrene`, `persist ${SUB_A}=cyrene`, `write ${SUB_A}=yuki`],
"the rollback writes the previous value back"
);
// Unchanged binding: no write, no request at all.
let writes = 0;
let persists = 0;
const noop: SessionPersonaIO = {
read: () => "yuki",
write: () => {
writes += 1;
},
persist: () => {
persists += 1;
return Promise.resolve();
},
};
same(await setSessionPersona(noop, SUB_A, "yuki"), true, "re-picking the bound role resolves true");
same([writes, persists], [0, 0], "re-picking the bound role fires no write and no request");
same(await setSessionPersona(noop, "", "yuki"), false, "an empty session id is refused");
// A second pick that lands while the first request is in flight owns the value:
// the older failure must not revert it.
let rejectFirst: (err: Error) => void = () => {};
let firstPersist = true;
const state2: Record<string, string> = { [SUB_A]: "yuki" };
const racing: SessionPersonaIO = {
read: (id) => state2[id] ?? "",
write: (id, personaId) => {
state2[id] = personaId;
},
persist: () =>
firstPersist
? new Promise<void>((_, reject) => {
firstPersist = false;
rejectFirst = reject;
})
: Promise.resolve(),
};
const first = setSessionPersona(racing, SUB_A, "cyrene");
const second = setSessionPersona(racing, SUB_A, "yuki");
rejectFirst(new Error("patch failed"));
same(await first, false, "the superseded request still reports its failure");
same(await second, true, "the newer pick is accepted");
same(state2[SUB_A], "yuki", "a stale failure does not revert the newer choice");
}
/** 4. The store's optimistic write re-resolves the rendered role at once. */
function testStoreRebind(): void {
useAppStore.setState({ personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_B });
useAppStore.getState().setSessionPersona(SUB_B, "yuki");
same(selectCurrentPersona(useAppStore.getState())?.id, "yuki", "the optimistic re-bind re-resolves the rendered role");
same(useAppStore.getState().sessions[SUB_B].personaId, "yuki", "the session list carries the new binding");
const after = useAppStore.getState().sessions;
useAppStore.getState().setSessionPersona("web:missing", "yuki");
ok(useAppStore.getState().sessions === after, "an unknown session id is a no-op (map untouched)");
same(
Object.keys(useAppStore.getState().sessions).sort(),
[MAIN, SUB_A, SUB_B].sort(),
"an unknown session id cannot invent a row"
);
useAppStore.getState().setSessionPersona(SUB_A, "yuki");
ok(useAppStore.getState().sessions === after, "re-picking the already-bound role is a no-op (map untouched)");
}
/** 5. A message keeps the role that spoke it (design/22 §7-5): switching a
* session's role must not re-attribute turns that were already sent. */
/** 3. A message keeps the role that spoke it (design/22 §7-5): a reply authored by
* another role keeps its own authoring role, it is never re-attributed to the
* session's role. */
function testMessageAuthoringRole(): void {
const yuki = PERSONAS[1];
const cyrene = PERSONAS[0];
@@ -228,7 +119,7 @@ function testMessageAuthoringRole(): void {
same([gone.persona?.id, gone.switched], ["yuki", false], "an unknown role id falls back to the session's role");
}
/** 6. `mood` frames are role-scoped: each role keeps its own mood. */
/** 4. `mood` frames are role-scoped: each role keeps its own mood. */
function testMoodBuckets(): void {
useAppStore.setState({ moods: {}, personas: PERSONAS, sessions: sessionFixtures(), activeSessionId: SUB_A });
dispatchFrame({ type: "mood", data: { mood: "happy", personaId: "yuki" } });
@@ -242,11 +133,9 @@ function testMoodBuckets(): void {
same(useAppStore.getState().moods[""], "calm", "clearing one bucket leaves the others alone");
}
async function run(): Promise<void> {
function run(): void {
testResolutionFollowsActiveSession();
testFallbackToDefaultRole();
await testOptimisticRebind();
testStoreRebind();
testMessageAuthoringRole();
testMoodBuckets();
console.log(`personaSession.test: ${checks} checks passed`);
-13
View File
@@ -81,11 +81,6 @@ export interface AppStore {
/** Replace the session metadata list. */
setSessions: (list: SessionMeta[]) => void;
/** Re-bind one session to a role locally — the optimistic write and the rollback
* target of `lib/sessionPersona.ts#setSessionPersona`. `""` re-binds it to
* "inherit the default role". A no-op for an unknown session or one already
* bound to that role. */
setSessionPersona: (sessionId: string, personaId: string) => void;
/** Zero one session's unread badge locally (Android 协同 B3/D4). Called when the
* user opens the session and its page has loaded, and on the `session_read`
* frame another end broadcasts — a no-op for an already-read or unknown
@@ -179,14 +174,6 @@ export const useAppStore = create<AppStore>()(
set({
sessions: Object.fromEntries(list.map((s) => [s.id, s])),
}),
setSessionPersona: (sessionId, personaId) =>
set((s) => {
const meta = s.sessions[sessionId];
if (!meta || (meta.personaId ?? "") === personaId) return s;
return {
sessions: { ...s.sessions, [sessionId]: { ...meta, personaId } },
};
}),
setActiveSession: (id) => set({ activeSessionId: id, messages: [] }),
clearUnread: (sessionId) =>
set((s) => {