feat(session,web): 会话角色不可变 — PATCH 拒 personaId + 移除前端换角色入口
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:
1 parent
f9430dee81
commit
c7ecc3a249
10 files changed
+193
-448
No files matched your search
@@ -1,12 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import {
|
||||
adminListPersonas,
|
||||
adminListSessions,
|
||||
createSubSession,
|
||||
patchSession,
|
||||
} from "../../lib/api";
|
||||
import type { PersonaRecord, SessionMeta } from "../../lib/types";
|
||||
import { adminListSessions, createSubSession } from "../../lib/api";
|
||||
import type { SessionMeta } from "../../lib/types";
|
||||
import { useAutoReload } from "../hooks";
|
||||
import { useAuditStore } from "../audit";
|
||||
import { AdminTable, Notice, PageHeader, type AdminColumn } from "../components";
|
||||
@@ -22,49 +17,11 @@ function exportMeta(sess: SessionMeta): void {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/** The role cell of one row: the session's bound role, editable in place. An id
|
||||
* that is not in the catalog (role deleted, catalog still loading) keeps its own
|
||||
* option, so the cell never silently reads as "inherit the default". */
|
||||
function PersonaCell({
|
||||
session,
|
||||
personas,
|
||||
disabled,
|
||||
onChange,
|
||||
}: {
|
||||
session: SessionMeta;
|
||||
personas: PersonaRecord[];
|
||||
disabled: boolean;
|
||||
onChange: (personaId: string) => void;
|
||||
}): React.ReactNode {
|
||||
const bound = session.personaId ?? "";
|
||||
const unknown = bound !== "" && !personas.some((p) => p.id === bound);
|
||||
return (
|
||||
<select
|
||||
value={bound}
|
||||
disabled={disabled}
|
||||
aria-label={`会话 ${session.id} 的角色`}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="max-w-[180px] rounded-[var(--mu-radius)] bg-[var(--mu-surface-2)] px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-[var(--mu-accent)]"
|
||||
>
|
||||
<option value="">(继承默认角色)</option>
|
||||
{unknown ? <option value={bound}>{bound}(不在角色列表)</option> : null}
|
||||
{personas.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}{p.isDefault ? " · 默认" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
/** The session table's columns. Built per render because two of the cells are
|
||||
* wired to the module's live state (the busy flag, the role catalog). */
|
||||
function buildColumns(opts: {
|
||||
personas: PersonaRecord[];
|
||||
busy: string | null;
|
||||
onChangePersona: (sessionId: string, personaId: string) => void;
|
||||
}): AdminColumn<SessionMeta>[] {
|
||||
const { personas, busy, onChangePersona } = opts;
|
||||
/** The session table's columns. Built per render because the action cell is wired to
|
||||
* the module's live busy flag. The 角色 cell is read-only: a session's role is fixed
|
||||
* when it is created (design/22 §8-1), so the panel shows the binding it was created
|
||||
* with instead of offering a re-bind — changing the role means creating a session. */
|
||||
function buildColumns(busy: string | null): AdminColumn<SessionMeta>[] {
|
||||
return [
|
||||
{ key: "type", title: "类型", render: (s) => <Tag tone={s.type === "main" ? "ok" : "accent"}>{s.type === "main" ? "主会话" : "次级会话"}</Tag> },
|
||||
{ key: "title", title: "标题", render: (s) => <span>{s.title ? s.title : <span className="text-[var(--mu-text-lo)]">(未命名)</span>}</span> },
|
||||
@@ -73,12 +30,9 @@ function buildColumns(opts: {
|
||||
key: "persona",
|
||||
title: "角色",
|
||||
render: (s) => (
|
||||
<PersonaCell
|
||||
session={s}
|
||||
personas={personas}
|
||||
disabled={busy !== null}
|
||||
onChange={(personaId) => onChangePersona(s.id, personaId)}
|
||||
/>
|
||||
<span className="font-mono text-xs">
|
||||
{s.personaId ? s.personaId : <span className="text-[var(--mu-text-lo)]">(继承默认角色)</span>}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{ key: "createdAt", title: "创建时间", render: (s) => <span className="text-xs">{new Date(s.createdAt).toLocaleString()}</span> },
|
||||
@@ -94,14 +48,12 @@ function buildColumns(opts: {
|
||||
];
|
||||
}
|
||||
|
||||
/** The page's two write actions, both audited. The list is re-read on success —
|
||||
* the PATCH reply is not a full session row — which also refreshes the row from
|
||||
* server truth instead of a local guess. */
|
||||
/** The page's write action, audited. The list is re-read on success so the new row
|
||||
* comes from server truth instead of a local guess. */
|
||||
function useSessionMutations(reload: () => void): {
|
||||
busy: string | null;
|
||||
fail: string | null;
|
||||
newSubSession: () => void;
|
||||
changePersona: (sessionId: string, personaId: string) => void;
|
||||
} {
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [fail, setFail] = useState<string | null>(null);
|
||||
@@ -132,19 +84,14 @@ function useSessionMutations(reload: () => void): {
|
||||
fail,
|
||||
newSubSession: () =>
|
||||
run("new", "session.create", () => createSubSession(), "新建次级会话失败"),
|
||||
changePersona: (sessionId, personaId) =>
|
||||
run(sessionId, "session.persona", () => patchSession(sessionId, { personaId }), "挂角色失败"),
|
||||
};
|
||||
}
|
||||
|
||||
export function SessionsModule(): React.ReactNode {
|
||||
const { data, loading, error, reload } = useAsyncData(adminListSessions, []);
|
||||
useAutoReload(reload, 10000);
|
||||
// The role catalog for the "挂角色" column (design/22 §4.6); the panel reads it
|
||||
// from the admin endpoint, and the write goes through the ordinary session PATCH.
|
||||
const personas = useAsyncData(adminListPersonas, []);
|
||||
const [query, setQuery] = useState("");
|
||||
const { busy, fail, newSubSession, changePersona } = useSessionMutations(reload);
|
||||
const { busy, fail, newSubSession } = useSessionMutations(reload);
|
||||
|
||||
const filtered = (data ?? []).filter((s) => {
|
||||
if (!query.trim()) return true;
|
||||
@@ -156,11 +103,7 @@ export function SessionsModule(): React.ReactNode {
|
||||
);
|
||||
});
|
||||
|
||||
const columns: AdminColumn<SessionMeta>[] = buildColumns({
|
||||
personas: personas.data ?? [],
|
||||
busy,
|
||||
onChangePersona: changePersona,
|
||||
});
|
||||
const columns: AdminColumn<SessionMeta>[] = buildColumns(busy);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -200,8 +143,7 @@ export function SessionsModule(): React.ReactNode {
|
||||
|
||||
<Notice>
|
||||
列表来自后端持久化会话(主会话 + 次级会话),主会话 id 固定。导出为会话元数据 JSON;身份判定永远由 actor 元数据 + 白名单 + 策略引擎完成,不依赖提示词。
|
||||
「角色」列是会话挂载的角色(design/22 §4.6):「继承默认角色」= 会话不绑定,随全局默认角色走;
|
||||
改角色决定该会话记忆/情绪/提示词的归属(owner-only + 审计)。
|
||||
「角色」列只读显示该会话创建时绑定的角色(design/22 §4.6/§8-1):会话角色在创建时确定、之后不可变,要换角色请新开会话(本页「新建次级会话」按默认角色新建,用户端侧栏的新建会话可选角色)。
|
||||
</Notice>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { createSubSession, getAuthToken, patchSession } from "../lib/api";
|
||||
import { setSessionPersona } from "../lib/sessionPersona";
|
||||
import { createSubSession, getAuthToken } from "../lib/api";
|
||||
import { useAppStore } from "../store/useAppStore";
|
||||
import type { PersonaRecord, SessionMeta } from "../lib/types";
|
||||
import { Icon } from "./Icon";
|
||||
@@ -8,14 +7,12 @@ import { NavItem } from "./NavItem";
|
||||
import { Button } from "./primitives";
|
||||
|
||||
/**
|
||||
* The conversation list in the rail (design/16 §2 + design/22 §4.6):
|
||||
* The conversation list in the rail (design/16 §2 + design/22 §4.6/§8-1):
|
||||
* - main session first, secondary sessions by creation time, unread badges;
|
||||
* - "新建会话" reveals a role picker, so a new conversation can start bound to a
|
||||
* role (empty = inherit the global default role);
|
||||
* - every row has a role menu that re-binds *that* session through
|
||||
* `PATCH /sessions/{id} {personaId}` (design/22 §4.6). Changing a session's
|
||||
* role changes which memory / emotion / system prompt the session talks to, so
|
||||
* it stays an explicit, per-session action instead of a global switch.
|
||||
* role (empty = inherit the global default role). This is the ONLY place a role
|
||||
* is picked: a session's role is fixed at creation (design/22 §8-1), so a row has
|
||||
* no role menu to re-bind it — switching roles means starting a new conversation.
|
||||
*
|
||||
* Kept out of `Nav.tsx` so no single component grows past the 80-line rule.
|
||||
*/
|
||||
@@ -25,12 +22,6 @@ export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.R
|
||||
const setSessions = useAppStore((s) => s.setSessions);
|
||||
const setActiveSession = useAppStore((s) => s.setActiveSession);
|
||||
const personas = useAppStore((s) => s.personas);
|
||||
const setSessionPersonaLocal = useAppStore((s) => s.setSessionPersona);
|
||||
/** The session whose role menu is expanded, or null when none is open. */
|
||||
const [menuFor, setMenuFor] = useState<string | null>(null);
|
||||
/** Set when a role change was rejected, so the row can say so instead of
|
||||
* silently showing the role the server still has. */
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
if (!getAuthToken()) return null;
|
||||
|
||||
@@ -58,104 +49,37 @@ export function SessionRail({ onNavigate }: { onNavigate: () => void }): React.R
|
||||
onNavigate();
|
||||
};
|
||||
|
||||
/** Re-bind one session to a role: written optimistically into the store and
|
||||
* rolled back when the PATCH is rejected (lib/sessionPersona.ts), so the rail
|
||||
* never keeps showing a binding the server refused. */
|
||||
const changePersona = async (sessionId: string, personaId: string) => {
|
||||
setFailed(false);
|
||||
const applied = await setSessionPersona(
|
||||
{
|
||||
read: (id) => useAppStore.getState().sessions[id]?.personaId ?? "",
|
||||
write: setSessionPersonaLocal,
|
||||
persist: (id, pid) => patchSession(id, { personaId: pid }),
|
||||
},
|
||||
sessionId,
|
||||
personaId
|
||||
);
|
||||
if (applied) setMenuFor(null);
|
||||
else setFailed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="px-2 pt-1 text-[11px] text-[var(--mu-text-lo)]">会话</div>
|
||||
{sessionList.map((s) => (
|
||||
<SessionEntry
|
||||
key={s.id}
|
||||
session={s}
|
||||
active={s.id === activeSessionId}
|
||||
menuOpen={menuFor === s.id}
|
||||
failed={failed && menuFor === s.id}
|
||||
personas={personas}
|
||||
onOpen={() => openSession(s.id)}
|
||||
onToggleMenu={() => {
|
||||
setFailed(false);
|
||||
setMenuFor(menuFor === s.id ? null : s.id);
|
||||
}}
|
||||
onChangePersona={(personaId) => void changePersona(s.id, personaId)}
|
||||
/>
|
||||
<SessionEntry key={s.id} session={s} active={s.id === activeSessionId} onOpen={() => openSession(s.id)} />
|
||||
))}
|
||||
<CreateSessionSection personas={personas} onCreated={adoptSession} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** One conversation row plus its role menu. */
|
||||
/** One conversation row. It carries no role affordance: the role is fixed when the
|
||||
* session was created (design/22 §8-1), so the only thing a row does is open. */
|
||||
function SessionEntry({
|
||||
session,
|
||||
active,
|
||||
menuOpen,
|
||||
failed,
|
||||
personas,
|
||||
onOpen,
|
||||
onToggleMenu,
|
||||
onChangePersona,
|
||||
}: {
|
||||
session: SessionMeta;
|
||||
active: boolean;
|
||||
menuOpen: boolean;
|
||||
failed: boolean;
|
||||
personas: PersonaRecord[];
|
||||
onOpen: () => void;
|
||||
onToggleMenu: () => void;
|
||||
onChangePersona: (personaId: string) => void;
|
||||
}): React.ReactNode {
|
||||
const label = session.title ?? (session.type === "main" ? "主会话" : "次级会话");
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<NavItem
|
||||
active={active}
|
||||
icon={session.type === "main" ? "chat" : "folder"}
|
||||
label={label}
|
||||
badge={session.unreadCount ?? 0}
|
||||
onClick={onOpen}
|
||||
trailing={
|
||||
<button
|
||||
onClick={onToggleMenu}
|
||||
aria-label={`${label} · 切换角色`}
|
||||
aria-expanded={menuOpen}
|
||||
title="切换角色"
|
||||
className="mr-1 inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-[var(--mu-radius-sm)] text-[var(--mu-text-lo)] transition-colors duration-200 hover:bg-[var(--mu-surface-5)] hover:text-[var(--mu-text-hi)]"
|
||||
>
|
||||
<Icon name="dna" size={14} />
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
{menuOpen ? (
|
||||
<div className="liquid-glass-soft mx-1 mb-1 flex flex-col gap-1.5 rounded-[var(--mu-radius)] p-2">
|
||||
<span className="text-[11px] text-[var(--mu-text-lo)]">会话角色</span>
|
||||
<RoleSelect
|
||||
value={session.personaId ?? ""}
|
||||
personas={personas}
|
||||
label={`${label}的角色`}
|
||||
onChange={onChangePersona}
|
||||
/>
|
||||
{failed ? (
|
||||
<span className="text-[10px] text-[var(--mu-err)]">切换失败,已保持原角色</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<NavItem
|
||||
active={active}
|
||||
icon={session.type === "main" ? "chat" : "folder"}
|
||||
label={label}
|
||||
badge={session.unreadCount ?? 0}
|
||||
onClick={onOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -236,11 +160,12 @@ function CreateSessionSection({
|
||||
);
|
||||
}
|
||||
|
||||
/** Miuix-styled role picker over `GET /personas` (design/22 §4.6). The empty
|
||||
* option means "inherit the global default role" — the value a session with no
|
||||
* binding has — so a session can always be returned to the default. A value that
|
||||
* is not in the catalog (role deleted, catalog still loading) is shown as its own
|
||||
* option rather than silently reading as "inherit". */
|
||||
/** Miuix-styled role picker over `GET /personas` (design/22 §4.6), used only by the
|
||||
* "new conversation" panel: the empty option means "inherit the global default
|
||||
* role", which is a perfectly normal way to create a session (and the only role
|
||||
* choice left — a created session cannot be re-bound, §8-1). Every value this
|
||||
* control shows comes from that catalog or is "", so it needs no "unknown role"
|
||||
* escape hatch. */
|
||||
function RoleSelect({
|
||||
value,
|
||||
personas,
|
||||
@@ -254,7 +179,6 @@ function RoleSelect({
|
||||
label: string;
|
||||
onChange: (personaId: string) => void;
|
||||
}): React.ReactNode {
|
||||
const unknown = value !== "" && !personas.some((p) => p.id === value);
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
@@ -264,7 +188,6 @@ function RoleSelect({
|
||||
className="min-w-0 flex-1 rounded-[var(--mu-radius-sm)] bg-[var(--mu-surface-2)] px-2 py-1 text-[11px] text-[var(--mu-text-hi)] outline-none transition-colors duration-200 focus:ring-1 focus:ring-[var(--mu-accent)] disabled:opacity-60"
|
||||
>
|
||||
<option value="">继承默认角色</option>
|
||||
{unknown ? <option value={value}>{value}(不在角色列表)</option> : null}
|
||||
{personas.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
|
||||
@@ -75,17 +75,17 @@ export function createSubSession(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
/** One PATCH /api/v1/sessions/{id} change set. At least one field must be set;
|
||||
* the gateway rejects an empty body with 400. `personaId: ""` explicitly
|
||||
* re-binds the session to "inherit the default role" (design/22 §4.5). */
|
||||
/** One PATCH /api/v1/sessions/{id} change set. At least one field must be set; the
|
||||
* gateway rejects an empty body with 400. `personaId` is deliberately absent: a
|
||||
* session's role is fixed at creation (design/22 §8-1), so the gateway answers 400
|
||||
* for any body carrying that key — switching roles means creating a new session. */
|
||||
export interface SessionPatch {
|
||||
title?: string;
|
||||
pinned?: boolean;
|
||||
personaId?: string;
|
||||
}
|
||||
|
||||
/** PATCH /api/v1/sessions/{id} — updates a session's display metadata and/or its
|
||||
* bound role. The reply echoes only the fields that were changed. */
|
||||
/** PATCH /api/v1/sessions/{id} — updates a session's display metadata (rename /
|
||||
* pin). The reply echoes only the fields that were changed. */
|
||||
export function patchSession(
|
||||
sessionId: string,
|
||||
patch: SessionPatch
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
/**
|
||||
* Re-binding one session to another role (design/22 §4.6).
|
||||
*
|
||||
* Picking a role is a two-step write: the local session list is updated first so
|
||||
* the avatar / name / wallpaper / mood switch at once, then `PATCH
|
||||
* /api/v1/sessions/{id}` persists it. A rejected request must not leave the UI
|
||||
* showing a role the server never accepted, so the optimistic write is rolled
|
||||
* back on failure — otherwise the next `GET /sessions` would silently snap every
|
||||
* consumer back and the user would be looking at a lie in the meantime.
|
||||
*
|
||||
* The store and the REST call arrive as `io` rather than being imported here, so
|
||||
* the ordering (write → persist → maybe roll back) is a plain function that can
|
||||
* be driven by a logic test with fakes — this project ships no test runner.
|
||||
*/
|
||||
|
||||
export interface SessionPersonaIO {
|
||||
/** The locally-known bound role id for a session ("" = inherits the default). */
|
||||
read: (sessionId: string) => string;
|
||||
/** Write the locally-known bound role id (the optimistic and rollback write). */
|
||||
write: (sessionId: string, personaId: string) => void;
|
||||
/** Persist the binding remotely; the returned promise rejects on failure. */
|
||||
persist: (sessionId: string, personaId: string) => Promise<unknown>;
|
||||
}
|
||||
|
||||
/** Bind `sessionId` to `personaId` ("" = inherit the default role).
|
||||
*
|
||||
* Resolves true when the server accepted it — including the no-op case where the
|
||||
* session is already bound to that role, which fires no request at all. Resolves
|
||||
* false when the session id is empty or the request failed (the local value is
|
||||
* then restored, so the store and the server list agree again). */
|
||||
export async function setSessionPersona(
|
||||
io: SessionPersonaIO,
|
||||
sessionId: string,
|
||||
personaId: string
|
||||
): Promise<boolean> {
|
||||
if (!sessionId) return false;
|
||||
const previous = io.read(sessionId);
|
||||
if (previous === personaId) return true;
|
||||
io.write(sessionId, personaId);
|
||||
try {
|
||||
await io.persist(sessionId, personaId);
|
||||
return true;
|
||||
} catch {
|
||||
// Roll back only while our optimistic value is still the one on screen: a
|
||||
// second pick that landed meanwhile owns its own request and its own answer,
|
||||
// and reverting it here would clobber a newer choice with a stale value.
|
||||
if (io.read(sessionId) === personaId) io.write(sessionId, previous);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -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`);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user