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:
1 parent
f454273c5a
commit
b480b04dd0
15 files changed
+733
-300
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user