Skip to content
6 changes: 6 additions & 0 deletions apps/web/src/components/app-shell/LeftSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ import {
activeTabIdAtom,
archiveInitialViewAtom,
currentWorkspaceIdAtom,
agentRuntimeEventsAtom,
settingsInitialTabAtom,
sidebarCollapsedAtom,
tabsAtom,
workspacePinnedIdsAtom,
} from '@/atoms'
import { removeRuntimeEvents } from '@/hooks/runtime-event-state'
import { threadMessagesCache } from '@/components/agent/thread-messages-cache'
import { CreateWorkspaceDialog } from '@/components/workspace/CreateWorkspaceDialog'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
import {
Expand Down Expand Up @@ -103,6 +106,7 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole
const [workspaces, setWorkspaces] = useAtom(agentWorkspacesAtom)
const [pinnedIds, setPinnedIds] = useAtom(workspacePinnedIdsAtom)
const setSettingsInitialTab = useSetAtom(settingsInitialTabAtom)
const setRuntimeEvents = useSetAtom(agentRuntimeEventsAtom)
const setArchiveInitialView = useSetAtom(archiveInitialViewAtom)
const [expandedWorkspaceIds, setExpandedWorkspaceIds] = useState<string[]>([])
const [createWorkspaceOpen, setCreateWorkspaceOpen] = useState(false)
Expand Down Expand Up @@ -362,6 +366,8 @@ export function LeftSidebar({ forceCollapsed = false }: { forceCollapsed?: boole
try {
await sidecarCall(AGENT_IPC_CHANNELS.TRASH_THREAD, { threadId: thread.id })
removeThreadFromNavigation(thread.id)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 相邻的 archiveThread(:334-352,同文件、同一菜单)没有接入这两行清理——归档路径上 Record 仍只增不减。

归档是完成会话的主要收尾动作(回收站是给不要的线程用的):长会话中归档 N 个线程,每个至多 2000 条事件对象 + threadMessagesCache 条目常驻内存直到重启。清掉并无副作用——归档线程恢复/重新打开时会走 AgentMessages.tsx:243 重新 hydrate,hydrateRuntimeEvents 也会从持久化事件重建 terminalStatus。建议本 PR 一并补上,或抽一个共享清理助手(见 ArchiveSettings 侧评论)。

setRuntimeEvents((prev) => removeRuntimeEvents(prev, thread.id))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 对仍在流式运行的线程,这条清理会被后续事件抵消。

sidecar 的 trashAgentThread(apps/sidecar/src/services/agent/agent-thread-manager.ts:799)只翻转 meta status,并不中止 run:(a) 已进 pendingRuntimeEventsRef 的批次会在下一次 rAF flush 时经 appendRuntimeEvents 重建刚删除的 prev[threadId];紧接着 (b) run 落盘触发 MESSAGE_APPENDEDGET_THREAD_RUNTIME_EVENTS(该 handler 直接读 session 目录,不按 trashed 状态过滤)再全量回灌至 2000 条。此时删除流程已执行完,再无任何路径清这个条目——本 PR 要修的内存驻留在"边跑边删"场景原样复发。

threadMessagesCache.invalidate(thread.id)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 两个 TRASH_THREAD 路径的清理子集漂移:ArchiveSettings.handleTrash(:44-45)还会 removeDraft/removeHistory,这里只清 events+cache。

agentInputDraftAtom / agentInputHistoryAtomatomWithStorage(键 agent-input-draft / agent-input-history,agent-atoms.ts:79+),持久化到 localStorage——从侧栏删除一个带未发送草稿的线程,其草稿与历史条目会跨重启永久孤儿化(线程已删,再无入口可触发清理)。同一用户动作因入口不同产生不同的持久化结果。

toast.success('已移入回收站')
} catch (error) {
console.error('[LeftSidebar] 移入回收站失败:', error)
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/components/settings/ArchiveSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ import { toast } from 'sonner'
import type { AgentThreadMeta } from '@lume/shared'
import { AGENT_IPC_CHANNELS } from '@lume/shared'
import { cn } from '@/lib/utils'
import { agentInputDraftAtom, agentInputHistoryAtom, agentWorkspacesAtom } from '@/atoms'
import { agentInputDraftAtom, agentInputHistoryAtom, agentRuntimeEventsAtom, agentWorkspacesAtom } from '@/atoms'
import { removeRuntimeEvents } from '@/hooks/runtime-event-state'
import { threadMessagesCache } from '@/components/agent/thread-messages-cache'
import { sidecarCall } from '@/lib/desktop-api'
import { removeDraft, removeHistory } from '@/lib/agent-input-draft-state'
import { ConfirmDialog } from '@/components/ui/confirm-dialog'
Expand All @@ -36,12 +38,15 @@ export function ArchiveSettings({ initialView }: { initialView?: 'archive' | 'tr
const workspaces = useAtomValue(agentWorkspacesAtom)
const setDraftState = useSetAtom(agentInputDraftAtom)
const setHistoryState = useSetAtom(agentInputHistoryAtom)
const setRuntimeEvents = useSetAtom(agentRuntimeEventsAtom)
const removeThreadInputState = React.useCallback(
(threadId: string) => {
setDraftState((prev) => removeDraft(prev, threadId))
setHistoryState((prev) => removeHistory(prev, threadId))
setRuntimeEvents((prev) => removeRuntimeEvents(prev, threadId))
threadMessagesCache.invalidate(threadId)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 建议把这套清理抽成共享 chokepoint(如 releaseThreadState(threadId)),而不是每个组件手拼一份子集。

本 PR 在 ArchiveSettings 组了 draft+history+events+cache 四件套,又在 LeftSidebar 手拼了不同的两件套,而线程移除实际有 6 个向量:归档、工作区 deleteLumeData 级联、项目删除级联、WelcomeView 删除重试线程、数据管理页清空回收站、sidecar 启动时 30 天自动清理——后四者完全没接清理(详见整体评论)。同类的 agentSubagentRunsAtom / agentSubagentWorkAtom 以及 useGlobalAgentListeners 的模块级 lifecycle Maps 在所有路径都无人清理。按调用点手拼的模式,每次都要重新发现全部向量,本 PR 相邻两个 handler 的不一致就是例证。

},
[setDraftState, setHistoryState],
[setDraftState, setHistoryState, setRuntimeEvents],
)
const [view, setView] = React.useState<View>(initialView ?? 'archive')
const [archivedThreads, setArchivedThreads] = React.useState<AgentThreadMeta[]>([])
Expand Down
42 changes: 41 additions & 1 deletion apps/web/src/hooks/runtime-event-state.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test'
import { appendRuntimeEvent, appendRuntimeEvents, hydrateRuntimeEvents } from './runtime-event-state'
import { appendRuntimeEvent, appendRuntimeEvents, hydrateRuntimeEvents, removeRuntimeEvents } from './runtime-event-state'
import type { AgentThreadRuntimeEventsResult, LumeRuntimeEvent } from '@lume/shared'

function runtimeEvent(event: Partial<LumeRuntimeEvent> & Pick<LumeRuntimeEvent, 'type'>): LumeRuntimeEvent {
Expand Down Expand Up @@ -290,6 +290,46 @@ describe('runtime-event-state', () => {
expect(hydrated['thread-1']?.terminalStatus).toBe('completed')
})

test('hydrate 同样受 MAX_EVENTS_PER_THREAD 上限约束', () => {
// 重开超长线程:sidecar 返回全量持久化事件,hydrate 后也必须 ≤ MAX,
// 与 append 路径上限一致(否则内存驻留无界,直到下一条 append 才触发 trim)。
const events: LumeRuntimeEvent[] = [
{
id: 'u0',
type: 'message.user.submitted',
threadId: 'thread-1',
runId: 'run-1',
createdAt: '2026-06-21T00:00:00.000Z',
text: 'hi',
} as LumeRuntimeEvent,
]
for (let i = 1; i <= 2500; i++) {
events.push({
id: `d${i}`,
type: 'assistant.delta',
threadId: 'thread-1',
runId: 'run-1',
messageId: `msg-${i}`,
sequence: i,
createdAt: '2026-06-21T00:00:00.000Z',
delta: `c${i}`,
} as LumeRuntimeEvent)
}
const hydrated = hydrateRuntimeEvents({}, { threadId: 'thread-1', events })

expect(hydrated['thread-1']?.events.length).toBeLessThanOrEqual(2000)
expect(hydrated['thread-1']?.events[0]?.type).toBe('message.user.submitted')
})

test('removeRuntimeEvents 删除线程条目;条目不存在时返回原引用', () => {
const prev = appendRuntimeEvent({}, runtimeEvent({ type: 'run.completed' }))
const next = removeRuntimeEvents(prev, 'thread-1')

expect(next['thread-1']).toBeUndefined()
expect(removeRuntimeEvents(next, 'thread-1')).toBe(next)
expect(removeRuntimeEvents(next, 'other-thread')).toBe(next)
})

test('超过 MAX_EVENTS_PER_THREAD 时优先丢头部 delta,保留结构事件与 user 提交', () => {
let state: Record<string, { events: LumeRuntimeEvent[] }> = {}
const u0 = {
Expand Down
15 changes: 14 additions & 1 deletion apps/web/src/hooks/runtime-event-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,9 @@ export function hydrateRuntimeEvents(
if (result.events.length === 0) {
return prev
}
const events = mergeHydratedRuntimeEvents(result.events, current?.events ?? [])
// 与 append 路径同上限:sidecar 回放不封顶,hydrate 不 trim 会让重开的超长线程
// 全量驻留内存(且直到下一条 append 前都无界)。trim 规则同 append:先丢头部 delta。
const events = trimRuntimeEvents(mergeHydratedRuntimeEvents(result.events, current?.events ?? []))

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 数据丢失风险:hydrate 新增的 trim 会永久丢失头部 turn 的助手正文。

trimRuntimeEvents 的安全前提是"被丢的头部 delta 对应 turn 已有 assistant.final 重建文本"(见 230-233 行注释),但该前提只在 live 路径成立——sidecar 回放投影(apps/sidecar/src/services/agent-runtime/runner/run-item-events.ts:164-200)只产出 assistant.delta / assistant.thinking_delta,从不产出 assistant.final(在整个 apps/sidecar/src 里 grep assistant.final 为空;final 只由 web 侧 live adapter 合成)。而 web 投影中无 final 的助手文本完全靠 delta 累积(runtime-event-message-projection.ts:297-309,text += event.delta)。

后果:超过 2000 条回放事件的长线程重开(或 MESSAGE_APPENDED 每条消息全量回灌,useGlobalAgentListeners.ts:331)→ 头部 delta 被丢弃 → 这些 turn 的助手回复/思考渲染为空。PR 前 hydrate 不封顶、重开可见全量历史,这是回归。且回放按"每内容块一条 delta"产出、mergeHydratedRuntimeEvents 又不做同流合并,回放事件计数比 live 合并后更膨胀,上限比 live 更容易触发。

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 follow-up 标记:渲染端 trim 只封内存,不封 IPC 载荷。

GET_THREAD_RUNTIME_EVENTS(apps/sidecar/src/rpc/agent-handlers.ts:780,listThreadRuntimeEvents 无 limit 参数)在每次 MESSAGE_APPENDED 时仍把整个持久化日志全量序列化、跨 IPC 传输、解析、merge,再被 trim 丢掉大部分。commit message 自己点名"sidecar 回放不封顶",但只修了渲染端一半——服务器侧(给 listThreadRuntimeEvents 加 limit/seq 下限,或 append 时不再全量回拉)建议留 follow-up 记录,避免一年后没人把长线程的 IPC 延迟归因到这。

if (current && sameRuntimeEvents(current.events, events)) {
return prev
}
Expand All @@ -91,6 +93,17 @@ export function hydrateRuntimeEvents(
}
}

/** 删除线程的 runtime events 条目(回收站/永久删除时调用,防止 Record 只增不减)。 */
export function removeRuntimeEvents(
prev: RuntimeEventState,
threadId: string,
): RuntimeEventState {
if (!(threadId in prev)) return prev
const next = { ...prev }
delete next[threadId]
return next
}

function mergeHydratedRuntimeEvents(
persistedEvents: LumeRuntimeEvent[],
liveEvents: LumeRuntimeEvent[],
Expand Down
Loading