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
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user