feat(web,ws): 会话级消息缓存 + 图桶 + 忙态按会话过滤(裁决 16.4.5/16.4.2 + 修 16.4.1 的帧缺口)

- 16.4.5:非激活会话的帧**不再丢弃**,按自己的 sessionId 落进该会话桶(新增 store/sessionCache.ts 纯函数 +
  useAppStore.sessionMessages/flowGraphs);切会话改为「存回/取出」而非清空;流式增量 O(1) 定位桶、
  只延长已存在的助手气泡(不凭一帧 delta 凭空造消息)
- 16.4.2:图按会话入桶(上限 12 个),Backthink 页取 sess-backthink 的图而不再被用户会话图顶替;
  空态区分「实时通道未连接」与「尚未产生链路图」;wire_runtime 的图源白名单注释按裁决口径重写(零行为改动)
- 修 16.4.1 的缺口:handlers/session.ts 补 `session_idle` / `session_interrupted`(此前落到未知帧兜底 ⇒
  sessionBusy 可能卡 true ⇒ 预约永不落地);忙态**置忙与置闲两侧**统一按 frameIsActive 过滤(只过滤置闲
  会让后台会话的忙帧点亮徽标而它自己的 idle 被丢 ⇒ 永久卡忙)
- 清理:删除 lib/streamScope.ts(口径已被 16.4.5 推翻)与只剩测试在用的 messageEventApplies
- 复核:tsc 0 错 + vite build 成功;messageSync 69 / session 19 项逻辑测试;4 条变异反证
This commit is contained in:
AskaEth committed 2026-10-04 13:41:02 +08:00
1 parent f454273c5a
commit b480b04dd0
15 files changed
+733 -300

No files matched your search

-30
View File
@@ -1,30 +0,0 @@
import { messageEventApplies } from "../store/useAppStore";
/** May a streaming frame write into the conversation the UI is showing?
*
* The hub broadcasts every session's stream to every connection (design/16 §4.2),
* while `appendChunk` / `endStream` / `setThinking` / `addTool` all target the
* *last* assistant message of the one message list — so a background
* conversation's tokens would otherwise be spliced into the bubble on screen.
* The frame's own session (`data.sessionId` when present, else the envelope's)
* decides.
*
* An unresolved active session (the session list has not loaded yet, or its fetch
* failed) keeps the pre-guard behavior rather than dropping the owner's own
* stream: the backend maps an empty request session to the main session, so those
* frames carry "web" while the store still holds "".
*
* Scope note (deliberate): a foreign frame is *dropped*, not buffered — the store
* holds the active session only, and that conversation's text arrives when it is
* switched to and its history is fetched. Buffering live deltas needs the
* per-session cache of design/16 §4.3, a separate step. */
export function streamFrameApplies(
ev: { sessionId?: string },
r: Record<string, unknown> | null,
activeSessionId: string
): boolean {
// Unresolved session list: keep the pre-guard path (see the doc comment).
if (!activeSessionId) return true;
const sessionId = typeof r?.sessionId === "string" ? r.sessionId : ev.sessionId;
return messageEventApplies(sessionId, activeSessionId);
}
+4 -1
View File
@@ -11,8 +11,11 @@ export function handleAgentEvent(ev: FrameEvent, r: FramePayload, s: StoreState)
return true;
}
case "graph_update": {
// 裁决 16.4.2:图按它自己的 sessionId 存进会话图桶,管理后台按会话取图 ——
// 后台思考 / 子会话的图不再顶掉管理员正在看的「当前会话」图(store 的两条
// 取图口径见 store/sessionCache.ts#currentGraph)。
const g = parseGraph(ev.data);
if (g) s.setFlowGraph(g);
if (g) s.setFlowGraph(g, ev.sessionId);
return true;
}
case "backthink": {
+4 -4
View File
@@ -1,5 +1,4 @@
import { streamFrameApplies } from "../../streamScope";
import type { FrameEvent, FramePayload, StoreState } from "./shared";
import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
/** 代审帧处理组:approval_notice —— 主代理代审小字(design/11 §4,裁决 13.4.3)。
* 敏感操作(如 terminal)经代审后后端广播该帧,`explain` 是后端产出的固定文案
@@ -7,8 +6,9 @@ import type { FrameEvent, FramePayload, StoreState } from "./shared";
* 命中返回 true,未命中返回 false 交给下一组。 */
export function handleApprovalEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
if (ev.type !== "approval_notice") return false;
// 与流式帧同一作用域规则:别的会话发生的代审不写进当前会话的小字。
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
// 小字是单槽状态,只认屏幕上这个会话:别的会话发生的代审不写进当前会话
// (与 stream_end / 忙态同一判据:shared 的 frameIsActive)。
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
// 没有文案就没有可渲染的小字 —— 丢掉这帧,而不是编造一句。
const explain = typeof r?.explain === "string" ? r.explain : "";
if (!explain) return true;
+20 -23
View File
@@ -1,12 +1,12 @@
import { messageEventApplies } from "../../../store/useAppStore";
import { MODES, type ChatMessage, type Mode } from "../../types";
import { parseAdminChannelMessage, parsePersistedMessage, toRecord } from "../../wsParse";
import type { FrameEvent, FramePayload, StoreState } from "./shared";
/** The fields of a `message_edited` payload the chat needs: the edited message's
* id and its new content. `sessionId` is carried inside the broadcast DTO (and on
* the frame envelope) so the frame can be filtered; `mode`/`mood` are optional —
* an edit never changes them, but the DTO carries them for completeness. */
* the frame envelope) so the store can confine the patch to that session's bucket;
* `mode`/`mood` are optional — an edit never changes them, but the DTO carries them
* for completeness. */
interface MessageEdited {
id: string;
content: string;
@@ -42,39 +42,36 @@ export function handleMessageEvent(ev: FrameEvent, r: FramePayload, s: StoreStat
switch (ev.type) {
case "message_persisted": {
// design/16 §4.2 — a user or assistant message was persisted and broadcast
// with its backend id. Only surface messages for the active session; the
// store dedups by id and adopts the local optimistic bubble (uid → backend
// id), so both the originating and remote browsers converge on one message.
// with its backend id. 裁决 16.4.5: the frame is written into the bucket its
// own `sessionId` names (the store routes it), so a background conversation
// keeps accumulating instead of being dropped. Within that bucket the store
// dedups by id and adopts the local optimistic bubble (uid → backend id), so
// both the originating and remote browsers converge on one message.
const msg = parsePersistedMessage(r?.message);
if (msg && msg.sessionId === s.activeSessionId) {
s.appendOrUpdateMessage(msg);
}
if (msg) s.appendOrUpdateMessage(msg);
return true;
}
case "message_edited": {
// design/16 §4.2 (D3 edit path) — the stored message's content was changed
// and broadcast with the updated DTO. The backend row stays the single source
// of truth; this only re-renders the bubble, and only in the session it
// belongs to (same filter as message_persisted). Patch by id — an id we do not
// hold is another session's (or an already-gone) message, never a new row.
// of truth; this only re-renders the bubble of the session the frame names
// (the store confines the patch to that session's bucket). Patch by id — an
// id we do not hold is another session's (or an already-gone) message, never
// a new row.
const edit = parseMessageEdited(ev.data);
if (!edit) return true;
if (messageEventApplies(edit.sessionId ?? ev.sessionId, s.activeSessionId)) {
const patch: Partial<ChatMessage> = { content: edit.content };
if (edit.mode) patch.mode = edit.mode;
if (edit.mood) patch.mood = edit.mood;
s.updateMessageById(edit.id, patch);
}
const patch: Partial<ChatMessage> = { content: edit.content };
if (edit.mode) patch.mode = edit.mode;
if (edit.mood) patch.mood = edit.mood;
s.updateMessageById(edit.id, patch, edit.sessionId ?? ev.sessionId);
return true;
}
case "message_deleted": {
// design/16 §4.2 (D3 delete path) — the message was removed server-side, so
// drop it by id instead of waiting for a manual re-read. The id is globally
// unique; sessionId is advisory and, when present, must be the active one.
// drop it by id instead of waiting for a manual re-read. The frame's session
// scopes the removal; without one the store searches by id (globally unique).
const id = r && typeof r.id === "string" ? r.id : "";
if (id && messageEventApplies(ev.sessionId, s.activeSessionId)) {
s.removeMessageById(id);
}
if (id) s.removeMessageById(id, ev.sessionId);
return true;
}
case "channel_persisted": {
@@ -0,0 +1,134 @@
// 会话编排忙闲帧的逻辑测试(design/15 §3/§4/§5;裁决 16.4.1)。
//
// 锁两件事:
// ① `session_idle` / `session_interrupted` 必须把忙态置回空闲 —— 此前二者没有 case,
// 落到未知帧兜底,忙态只能靠 `stream_end` 回落;回合卡住/被中断时
// `useAppStore.sessionBusy` 一直为 true,`components/modeSwitch.ts` 的
// 「预约切换」判据(`useHerActive`:sessionBusy || streaming || sending)永不落地;
// ② 置忙与置闲必须用**同一把尺子**(屏幕上的那个会话),否则后台会话的 bus 帧点亮徽标、
// 它自己的 idle 帧又被过滤掉 ⇒ 永久卡忙(design/16 §4.2 已登记的单槽债)。
//
// 项目没有前端测试框架/jsdom(`useHerActive` 是钩子,需要 DOM,故不在此覆盖),所以这是一个
// 零依赖的纯 node 检查:它驱动真实的帧解码器(lib/ws.ts 的 dispatchFrame)与真实的 store,
// 断言 `SessionStatusHint` / `useHerActive` 所读的那几个字段,并在第一个失败处抛出。
//
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json;banner 补上 store 的
// persist 中间件在 node 下探测的 window.localStorage):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/lib/ws/handlers/session.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-session-busy.test.mjs \
// && node /tmp/cyrene-session-busy.test.mjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它
// 只从项目内导入、不 import node:*。
import { dispatchFrame } from "../../ws";
import { useAppStore } from "../../../store/useAppStore";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function same<T>(got: T, want: T, what: string): void {
ok(JSON.stringify(got) === JSON.stringify(want), `${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`);
}
const MAIN = "web";
const OTHER = "web:sub-1";
/** 每个场景前把忙态复位成「她静默、无挂起」,并把焦点放在 `active` 上。 */
function seed(active: string): void {
useAppStore.setState({ activeSessionId: active, sessionBusy: false, pendingCount: 0 });
}
function busy(): boolean {
return useAppStore.getState().sessionBusy;
}
function pending(): number {
return useAppStore.getState().pendingCount;
}
// ① 活动会话:置忙 → 空闲帧收口。这正是 16.4.1 的预约切换所依赖的「她静默下来」。
seed(MAIN);
dispatchFrame({ type: "session_busy", sessionId: MAIN, data: { sessionId: MAIN, mode: "thinking", busy: true, pendingCount: 0 } });
ok(busy(), "session_busy 置忙");
dispatchFrame({ type: "session_idle", sessionId: MAIN, data: { sessionId: MAIN, mode: "idle" } });
ok(!busy(), "session_idle 必须把忙态置回空闲(此前的缺口:忙态卡到 stream_end)");
same(pending(), 0, "session_idle 顺带清空挂起数(队列已清空)");
// 缺 `busy` 字段的 session_busy 按忙处理(后端帧只有 {sessionId, mode} 时也不误判为闲)。
seed(MAIN);
dispatchFrame({ type: "session_busy", sessionId: MAIN, data: { sessionId: MAIN, mode: "executing" } });
ok(busy(), "session_busy 缺 busy 字段时默认置忙");
// 显式 busy:false 仍是空闲。
seed(MAIN);
dispatchFrame({ type: "session_busy", sessionId: MAIN, data: { sessionId: MAIN, busy: false } });
ok(!busy(), "session_busy{busy:false} 读作空闲");
// ② interrupt:当前任务取消 + pending 清空(Docs/api-client.md §3.2。
// 帧带 {sessionId, mode, dropped},没有 busy 字段,语义就是「不再忙」)。
seed(MAIN);
useAppStore.setState({ sessionBusy: true, pendingCount: 3 });
dispatchFrame({ type: "session_interrupted", sessionId: MAIN, data: { sessionId: MAIN, mode: "idle", dropped: 2 } });
ok(!busy(), "session_interrupted 必须把忙态置回空闲");
same(pending(), 0, "session_interrupted 清空挂起数(pending 被丢弃)");
// ③ 会话级过滤:单槽忙态跨会话会串,所以置忙与置闲都只认屏幕上这个会话。
// 别的会话忙:不动这里的徽标。
seed(MAIN);
dispatchFrame({ type: "session_busy", sessionId: OTHER, data: { sessionId: OTHER, mode: "thinking" } });
ok(!busy(), "别的会话置忙不得点亮当前会话的忙态");
// 别的会话回空闲:不清掉当前会话的忙(否则当前会话看上去凭空静默了)。
seed(MAIN);
useAppStore.setState({ sessionBusy: true, pendingCount: 2 });
dispatchFrame({ type: "session_idle", sessionId: OTHER, data: { sessionId: OTHER, mode: "idle" } });
ok(busy(), "别的会话回空闲不得清掉当前会话的忙态");
same(pending(), 2, "别的会话回空闲不得清掉当前会话的挂起数");
dispatchFrame({ type: "session_interrupted", sessionId: OTHER, data: { sessionId: OTHER, mode: "idle", dropped: 9 } });
ok(busy(), "别的会话被中断不得清掉当前会话的忙态");
// 置忙 + 置闲配对:后台会话从忙到闲全程不影响前台(这是「同一把尺子」的关键——
// 若只过滤置闲,后台会话的忙态会永久卡在徽标上)。
seed(MAIN);
dispatchFrame({ type: "session_busy", sessionId: OTHER, data: { sessionId: OTHER, mode: "thinking" } });
dispatchFrame({ type: "session_idle", sessionId: OTHER, data: { sessionId: OTHER, mode: "idle" } });
ok(!busy(), "后台会话的忙 → 闲不得在当前会话留下卡住的徽标");
// ④ 无会话归属的帧(旧后端 / 会话列表未解析)沿用旧行为:不因为对不上就丢掉。
seed(MAIN);
dispatchFrame({ type: "session_busy", data: { mode: "thinking" } });
ok(busy(), "无 sessionId 的 session_busy 沿用旧行为(置忙)");
dispatchFrame({ type: "session_idle", data: { mode: "idle" } });
ok(!busy(), "无 sessionId 的 session_idle 沿用旧行为(置闲)");
// 激活会话尚未解析(列表没回来,仍是 "")时同样沿用旧行为。
seed("");
dispatchFrame({ type: "session_busy", sessionId: MAIN, data: { sessionId: MAIN, mode: "thinking" } });
ok(busy(), "激活会话未解析时 session_busy 照旧置忙");
dispatchFrame({ type: "session_idle", sessionId: MAIN, data: { sessionId: MAIN, mode: "idle" } });
ok(!busy(), "激活会话未解析时 session_idle 照旧置闲");
// ⑤ 这两帧必须被消费(不得落进未知帧兜底,往 deviceUpdates 里堆垃圾)。
seed(MAIN);
const before = useAppStore.getState().deviceUpdates.length;
dispatchFrame({ type: "session_idle", sessionId: MAIN, data: { sessionId: MAIN, mode: "idle" } });
dispatchFrame({ type: "session_interrupted", sessionId: MAIN, data: { sessionId: MAIN, mode: "idle" } });
same(useAppStore.getState().deviceUpdates.length, before, "idle/interrupted 不进未知帧兜底");
// ⑥ 与 stream_end 的关系:回合正常结束仍按老路子收口(两条路互不打架)。
seed(MAIN);
useAppStore.setState({ sessionBusy: true, pendingCount: 1 });
dispatchFrame({ type: "stream_end", sessionId: MAIN, data: { stopReason: "end_turn" } });
ok(!busy(), "stream_end 仍然收口忙态");
same(pending(), 0, "stream_end 仍然清空挂起数");
console.log(`session busy logic test: ${checks} checks passed`);
+19 -3
View File
@@ -2,10 +2,11 @@ import { useModeStore } from "../../../store/useModeStore";
import { sessionReadTarget } from "../../sessionRead";
import { MODES, type AwakenState, type Mode } from "../../types";
import { parseAwakenTool } from "../../wsParse";
import type { FrameEvent, FramePayload, StoreState } from "./shared";
import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
/** 会话/角色状态帧处理组:capacity_awaken / session_busy / session_pending /
* session_read / mode_switch / mood。命中返回 true,未命中返回 false 交给下一组。 */
/** 会话/角色状态帧处理组:capacity_awaken / 会话编排忙闲(session_busy /
* session_pending / session_idle / session_interrupted)/ session_read /
* mode_switch / mood。命中返回 true,未命中返回 false 交给下一组。 */
export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
switch (ev.type) {
case "capacity_awaken": {
@@ -23,12 +24,27 @@ export function handleSessionEvent(ev: FrameEvent, r: FramePayload, s: StoreStat
case "session_busy":
case "session_pending": {
// design/15 §5 — 她忙/有挂起消息时的轻状态(不阻塞输入框)。两者同构:都是 {busy, pendingCount}。
// 忙态是**单槽全局**状态,只认屏幕上这个会话(见下方 session_idle 的说明)。
if (!frameIsActive(frameSessionId(ev, r), s.activeSessionId)) return true;
const busy = typeof r?.busy === "boolean" ? r.busy : true;
const pendingCount =
typeof r?.pendingCount === "number" ? r.pendingCount : s.pendingCount;
s.setSessionBusy(busy, pendingCount);
return true;
}
case "session_idle":
case "session_interrupted": {
// design/15 §3/§4/§5 — 会话回到空闲:`session_idle`(队列清空)与
// `session_interrupted`(用户 interrupt:当前任务取消、pending 丢弃)都自带
// `{sessionId, mode}`、**没有 busy 字段**,语义就是「这个会话不再忙」。
// 缺这两个 case 时忙态只能靠 `stream_end` 回落:回合卡住/被中断时
// `useAppStore.sessionBusy` 一直为 true,裁决 16.4.1 的「预约切换」永不落地。
const sid = frameSessionId(ev, r);
// 单槽忙态跨会话会串(design/16 §4.2 已登记的债):置忙与置闲必须用同一把尺子,
// 否则后台会话的 bus 帧把徽标点亮、它自己的 idle 帧又被过滤掉 ⇒ 永久卡忙。
if (frameIsActive(sid, s.activeSessionId)) s.setSessionBusy(false, 0);
return true;
}
case "session_read": {
// 协同 B3/D4 — one end marked the conversation read, so the unread badge goes
// out on every end. Only the addressed session is touched (a frame naming
@@ -12,3 +12,25 @@ export type FramePayload = Record<string, unknown> | null;
/** dispatchFrame 入口处取到的 store 快照类型,按参数传给各帧处理组。 */
export type StoreState = ReturnType<typeof useAppStore.getState>;
/** 一帧所属的会话 id:payload 里带的优先(结构化流帧在回合边界由 loop 注入),
* 其次帧壳的;两者都没有时返回 undefined,调用方据此落回「当前激活会话」。
*
* 裁决 16.4.5 后这里返回的 id 决定**写进哪个会话的消息桶**(而不是决定丢弃)。 */
export function frameSessionId(ev: FrameEvent, r: FramePayload): string | undefined {
if (r && typeof r.sessionId === "string" && r.sessionId) return r.sessionId;
return ev.sessionId || undefined;
}
/** 这帧是不是屏幕上这个会话的?只用于**单槽全局状态**的判定(忙态 / 上下文 /
* 思考累积器)—— 消息内容本身按各自的会话桶写入,不再受此限制。
*
* 激活会话尚未解析(列表没加载出来,仍是 "")时返回 true,沿用旧行为:不因为
* 会话 id 还没对上就把主人正看着的流丢掉。 */
export function frameIsActive(
sessionId: string | undefined,
activeSessionId: string
): boolean {
if (!sessionId || !activeSessionId) return true;
return sessionId === activeSessionId;
}
+16 -18
View File
@@ -1,27 +1,26 @@
import { streamFrameApplies } from "../../streamScope";
import type { FrameEvent, FramePayload, StoreState } from "./shared";
import { frameIsActive, frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
/** 流式正文帧处理组:stream_chunk / stream_end / message.start / message.delta /
* message.end。命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 */
* message.end。命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。
*
* 裁决 16.4.5:帧按自己的 `sessionId` 落进对应会话的消息桶,**不再**因为不属于
* 激活会话而被丢掉 —— 后台会话的正文继续累积,切过去就能看到完整的这一轮。 */
export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
switch (ev.type) {
case "stream_chunk": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
if (r && typeof r.delta === "string") s.appendChunk(r.delta, frameSessionId(ev, r));
return true;
}
case "stream_end": {
// Another session's turn ending must not close the streaming bubble on
// screen or clear this conversation's busy badge (both are single-slot
// state: the last message's flag, and one shell-wide `sessionBusy`).
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
s.endStream();
// The assistant reply stopped streaming, so the immediate turn is over:
// close the busy/pending window so the badge does not stick. If the backend
// keeps a task/subagent running it re-emits session_busy (design/15 §5).
s.setSessionBusy(false, 0);
if (r && typeof r.stopReason === "string") {
s.setContext({ lastAgentMode: s.context.lastAgentMode });
const sid = frameSessionId(ev, r);
s.endStream(sid);
// 忙态 / 上下文是**单槽全局**状态(design/15 §5),所以只有屏幕上这个会话的
// 回合结束才收口;别的会话结束只能收它自己气泡的流,不能清掉这里的徽标。
if (frameIsActive(sid, s.activeSessionId)) {
s.setSessionBusy(false, 0);
if (r && typeof r.stopReason === "string") {
s.setContext({ lastAgentMode: s.context.lastAgentMode });
}
}
return true;
}
@@ -32,8 +31,7 @@ export function handleStreamEvent(ev: FrameEvent, r: FramePayload, s: StoreState
return true;
}
case "message.delta": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
if (r && typeof r.delta === "string") s.appendChunk(r.delta);
if (r && typeof r.delta === "string") s.appendChunk(r.delta, frameSessionId(ev, r));
return true;
}
case "message.end": {
+23 -21
View File
@@ -1,43 +1,45 @@
import { streamFrameApplies } from "../../streamScope";
import type { FrameEvent, FramePayload, StoreState } from "./shared";
import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
/** Accumulator for a streamed thinking segment (thinking.* events). Reset on
* thinking.start so a mid-turn reconnect can rebuild the collapsed block
* without relying on prior state. */
let pendingThinking = "";
/** Accumulator for a streamed thinking segment (thinking.* events), **per session**:
* a background turn's reasoning must not be spliced into the segment being built
* for the conversation on screen (裁决 16.4.5). Reset on thinking.start so a
* mid-turn reconnect can rebuild the collapsed block without relying on prior state. */
const pendingThinking: Record<string, string> = {};
/** 累积器与写入用的会话键:帧带的 sessionId 优先,缺席时归当前激活会话。 */
function thinkKey(ev: FrameEvent, r: FramePayload, s: StoreState): string {
return frameSessionId(ev, r) ?? s.activeSessionId;
}
/** 思考流帧处理组:thinking.start / thinking.delta / thinking.end + 遗留单帧 thinking。
* 命中(含组内主动丢弃)返回 true,未命中返回 false 交给下一组。 */
export function handleThinkingEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
switch (ev.type) {
case "thinking.start": {
// Guarded like the deltas: only the active conversation may reset the
// accumulator (which also bounds a switched-away session's stale text).
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
pendingThinking = "";
pendingThinking[thinkKey(ev, r, s)] = "";
return true;
}
case "thinking.delta": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
if (r && typeof r.delta === "string") {
pendingThinking += r.delta;
s.setThinking(pendingThinking);
}
if (!r || typeof r.delta !== "string") return true;
const key = thinkKey(ev, r, s);
const text = (pendingThinking[key] ?? "") + r.delta;
pendingThinking[key] = text;
s.setThinking(text, frameSessionId(ev, r));
return true;
}
case "thinking.end": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
if (pendingThinking) s.setThinking(pendingThinking);
pendingThinking = "";
const key = thinkKey(ev, r, s);
const text = pendingThinking[key] ?? "";
if (text) s.setThinking(text, frameSessionId(ev, r));
delete pendingThinking[key];
return true;
}
case "thinking": {
// Legacy single-shot CoT frame — renders into the same bubble.
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
if (typeof ev.data === "string") {
s.setThinking(ev.data);
s.setThinking(ev.data, frameSessionId(ev, r));
} else if (r && typeof r.text === "string") {
s.setThinking(r.text);
s.setThinking(r.text, frameSessionId(ev, r));
}
return true;
}
+9 -10
View File
@@ -1,17 +1,18 @@
import { streamFrameApplies } from "../../streamScope";
import { frameSessionId, type FrameEvent, type FramePayload, type StoreState } from "./shared";
import { parseTool, parseToolCall } from "../../wsParse";
import type { FrameEvent, FramePayload, StoreState } from "./shared";
/** 工具帧处理组:tool_call.start / tool_call.end / tool_call.delta /
* tool_execution.update + 遗留 tool_used。命中(含组内主动丢弃)返回 true,
* 未命中返回 false 交给下一组。 */
* 未命中返回 false 交给下一组。
*
* 裁决 16.4.5:工具卡按帧的 `sessionId` 落在该会话的助手气泡上(其它会话的调用
* 卡不会跑到屏幕上的这一轮里)。 */
export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
switch (ev.type) {
case "tool_call.start":
case "tool_call.end": {
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
const tool = parseToolCall(ev.data);
if (tool) s.addTool(tool);
if (tool) s.addTool(tool, frameSessionId(ev, r));
return true;
}
case "tool_call.delta": {
@@ -20,17 +21,15 @@ export function handleToolEvent(ev: FrameEvent, r: FramePayload, s: StoreState):
return true;
}
case "tool_execution.update": {
// Same card as tool_call.* — same guard.
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
// Same card as tool_call.* — same routing.
const tool = parseToolCall(ev.data);
if (tool) s.addTool(tool);
if (tool) s.addTool(tool, frameSessionId(ev, r));
return true;
}
case "tool_used": {
// Legacy tool echo — same card as tool_call.*.
if (!streamFrameApplies(ev, r, s.activeSessionId)) return true;
const tool = parseTool(ev.data);
if (tool) s.addTool(tool);
if (tool) s.addTool(tool, frameSessionId(ev, r));
return true;
}
default: